Compare commits

..
28 Commits
Author SHA1 Message Date
huangsucan 96680fd4c6 修复监控bug 2026-09-16 09:01:27 +08:00
huangsucan 432ed2a70c fix: 步骤运行时间排除区间延迟(延迟期间暂停stepStopwatch,Delay命令只记实际命令耗时) 2026-09-14 17:21:39 +08:00
huangsucan 186eec4e75 feat: 新增CommandRunningTime步骤耗时统计命令(序号区间RunTime求和,依赖倒置+AsyncLocal作用域隔离) 2026-09-14 17:08:56 +08:00
huangsucan b1a160d159 feat: ZLGCANFD新增获取报文信号值函数(最新解码报文缓存+按通道/帧ID/信号名查询,失败报错返回0) 2026-09-14 14:36:05 +08:00
huangsucan 3d30d33f7b 删除不相关代码文件 2026-09-14 14:27:03 +08:00
huangsucan 1355753e41 fix: 调用方适配ConcurrentDictionary(Add→TryAdd、Remove→TryRemove),与ACP P0提交对齐 2026-09-14 13:59:02 +08:00
huangsucan dfbc5797bf P3: 数据库文件膨胀→启动时检查文件过期(默认180天),过期则归档重命名(SQL_归档_yyyyMMdd_HHmmss.db),不删除记录 2026-09-14 13:54:54 +08:00
huangsucan 7043a66a28 P2: Flexible四类通信锁从全局静态改为按端口/IP粒度(ConcurrentDictionary),不同物理端口互不阻塞 2026-09-14 13:53:46 +08:00
huangsucan 8d8df56544 fix: HardwareDataBroadcaster幂等Discover与静默异常增强(失败计数+通道暂停+日志) 2026-09-14 13:50:14 +08:00
huangsucan 8b53d8e921 fix: GlobalInfo四个作用域字典改为ConcurrentDictionary提升线程安全 2026-09-14 13:48:49 +08:00
huangsucan 31255fadeb feat: 移植DeviceHealthMonitor心跳重连机制并集成到DeviceManager 2026-09-14 13:46:45 +08:00
huangsucan c4fc2d80fd can修改 2026-09-12 20:31:45 +08:00
huangsucan 4acb4d8d13 启动加载页 2026-08-31 14:55:21 +08:00
huangsucan 7a56eb98d2 测试报告导出修改 2026-08-30 15:36:57 +08:00
huangsucan 61d7293afb 测试报告导出优化 2026-08-27 14:51:15 +08:00
huangsucan 47b4d3837b 弹窗添加 2026-08-27 14:26:22 +08:00
huangsucan 6a59552bc7 现场优化7 2026-08-27 11:08:36 +08:00
huangsucan 38f594e4aa 查询返回数据转double 2026-08-19 20:04:10 +08:00
huangsucan fb6e5b3cb1 现场优化6 2026-08-17 15:52:48 +08:00
huangsucan 2bb8313f99 现场优化5 2026-08-15 23:28:19 +08:00
huangsucan 92eab2f626 现场优化4 2026-08-15 18:43:38 +08:00
huangsucan 8d941d92f0 现场优化3 2026-08-15 10:54:28 +08:00
huangsucan e392527592 现场优化2 2026-08-13 13:35:43 +08:00
huangsucan 30f5f6f8d6 现场程序优化 2026-08-12 18:16:50 +08:00
huangsucan b43090e56e stepmanager的UI更新优化 2026-08-10 11:56:50 +08:00
huangsucan a9355087ad 子程序优化 2026-08-07 16:30:13 +08:00
huangsucan 8daa1df8e9 串口连接修复 2026-08-03 13:07:03 +08:00
huangsucan 7776e6c7ab 加载配置文件逻辑优化 2026-07-30 13:23:59 +08:00
77 changed files with 8064 additions and 867 deletions
+59 -2
View File
@@ -1,4 +1,5 @@
using Common;
using Command;
using ADP.ViewModels;
using ADP.ViewModels.Dialogs;
using ADP.Views;
@@ -12,6 +13,8 @@ using Service.Interface;
using System.Configuration;
using System.Data;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using UIShare.PubEvent;
using static System.Runtime.InteropServices.JavaScript.JSType;
@@ -23,6 +26,7 @@ using DeviceCommand.Base;
using AutoMapper;
using Microsoft.Extensions.Logging.Abstractions;
using ADP.Profiles;
using Prism.Dialogs;
using ;
namespace ADP
@@ -60,6 +64,56 @@ namespace ADP
}
protected override void OnInitialized()
{
// 向命令库 CommandDialog 注入弹窗能力(命令库是纯 .NET 类库,不引用 WPF,这里通过委托实现依赖倒置)
var dialogService = Container.Resolve<IDialogService>();
CommandDialog. = (, , , , ct) =>
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationTokenRegistration registration = default;
if ()
{
registration = ct.Register(() => tcs.TrySetCanceled(ct));
}
// 命令可能在后台线程执行,统一切回 UI 线程弹窗
Application.Current.Dispatcher.Invoke(() =>
{
var param = new DialogParameters
{
{ "Title", switch
{
CommandDialog.DialogType.Warning => "警告",
CommandDialog.DialogType.Error => "错误",
_ => "信息提示"
} },
{ "Message", ?? string.Empty },
{ "Icon", switch
{
CommandDialog.DialogType.Warning => "warn",
CommandDialog.DialogType.Error => "error",
_ => "info"
} },
{ "ShowOk", true },
{ "AutoCloseSeconds", (double) }
};
if ()
{
// 阻塞:用户手动关闭或自动关闭后才继续执行步骤
dialogService.ShowDialog("MessageBox", param, _ =>
{
registration.Dispose();
tcs.TrySetResult(true);
});
}
else
{
// 非阻塞:弹出后步骤立即继续(不等关闭回调)
dialogService.Show("MessageBox", param, _ => { });
tcs.TrySetResult(true);
}
});
return tcs.Task;
};
// 配置全局日志分发器:按 CurrentScope 路由到对应 LogArea
var globalInfo = Container.Resolve<GlobalInfo>();
LoggerHelper.Progress = new ScopeLogDispatcher(globalInfo);
@@ -67,6 +121,8 @@ namespace ADP
//初始化数据库
DatabaseConfig.SetTenant(10001);
DatabaseConfig.InitSqlite();
// 启动时检查数据库文件是否过期,过期则归档(重命名加时间戳),后续会自动创建新库
DatabaseConfig.TryArchiveOldDatabase();
DatabaseConfig.CreateDatabaseAndCheckConnection(createDatabase: true, checkConnection: true);
SqlSugarContext.InitDatabase();
//显示登录窗口
@@ -84,8 +140,8 @@ namespace ADP
return;
}
string strUserSn = "ACA89B2C1194650D4E93D97BE189FFCC";
string strApiKey = "C6D01BCD442347822876F8232245DE9422643C167B8C61CE4DF6049990E7395588675A8FC6AA4BF7AD353D669C993236BEA83F77551753885A47AF34185CC3A7";
string strUserSn = "ACC77E6F627236F3D02FA8EA1BE35794";
string strApiKey = "9185203127F9DE4FB4BC2F073F6CC583D9BED1B6713EF00A0C06625EDD0FAE862164280F5B2AADC090C4878E96DDFE05DDE3952F9B6F1FA483A340FB34A5D83B";
Byte[] buffer = new Byte[256];
buffer = System.Text.Encoding.Default.GetBytes(strUserSn);
@@ -152,6 +208,7 @@ namespace ADP
//注册服务
containerRegistry.Register<IMonitorValueService, MonitorValueService>();
containerRegistry.Register<ITestReportService, TestReportService>();
containerRegistry.Register<ITestCheckRecordService, TestCheckRecordService>();
}
//指定模块加载方式(需要手动将模块生成的dll放入Modules文件夹中)
protected override IModuleCatalog CreateModuleCatalog()
@@ -1,7 +1,9 @@
using UIShare.PubEvent;
using UIShare.ViewModelBase;
using System;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
namespace ADP.ViewModels.Dialogs
{
@@ -69,6 +71,11 @@ namespace ADP.ViewModels.Dialogs
public DialogCloseListener RequestClose { get; set; }
/// <summary>
/// 自动关闭定时器:自动关闭秒数大于 0 时启动,到时自动关闭弹窗(同时解除阻塞等待)
/// </summary>
private DispatcherTimer _autoCloseTimer;
public MessageBoxViewModel(IContainerProvider containerProvider):base(containerProvider)
{
YesCommand = new DelegateCommand(OnYes);
@@ -93,6 +100,7 @@ namespace ADP.ViewModels.Dialogs
public override void OnDialogClosed()
{
_autoCloseTimer?.Stop();
_eventAggregator.GetEvent<OverlayEvent>().Publish(false);
}
@@ -117,6 +125,21 @@ namespace ADP.ViewModels.Dialogs
ShowNo = parameters.GetValue<bool>("ShowNo");
ShowOk = parameters.GetValue<bool>("ShowOk");
ShowCancel = parameters.GetValue<bool>("ShowCancel");
// 自动关闭:秒数大于 0 时启动定时器,到时自动关闭(供命令库 弹窗 命令的自动关闭参数使用)
if (parameters.TryGetValue("AutoCloseSeconds", out double autoCloseSeconds) && autoCloseSeconds > 0)
{
_autoCloseTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(autoCloseSeconds)
};
_autoCloseTimer.Tick += (s, e) =>
{
_autoCloseTimer.Stop();
RequestClose.Invoke(new DialogResult(ButtonResult.OK));
};
_autoCloseTimer.Start();
}
}
#endregion
}
+39 -3
View File
@@ -154,6 +154,24 @@ namespace ADP.ViewModels
public ICommand MonitorValueSettingCommand { get; set; }
public ICommand GetFileStringCommand { get; set; }
public ICommand SilenceBuzzerCommand { get; set; }
public ICommand GoBackCommand { get; set; }
#endregion
#region
public bool CanGoBack =>
(CurrentContext?.MainNav.CanGoBack ?? false) ||
(CurrentContext?.ErrorNav.CanGoBack ?? false);
private void OnGoBack()
{
var ctx = CurrentContext;
if (ctx == null) return;
// 工具栏按钮同时重置两个 Tab 的导航
ctx.MainNav.Reset(ctx.Program, "主程序");
ctx.ErrorNav.Reset(ctx.Program, "错误程序");
}
#endregion
public ShellViewModel(IContainerProvider containerProvider)
@@ -188,8 +206,9 @@ namespace ADP.ViewModels
MonitorValueSettingCommand = new DelegateCommand(MonitorValueSetting);
GetFileStringCommand = new DelegateCommand(GetFileString);
SilenceBuzzerCommand = new AsyncDelegateCommand(SilenceBuzzer);
GoBackCommand = new DelegateCommand(OnGoBack);
_globalInfo.ContextDic.Add("default", new ScopedContext());
_globalInfo.ContextDic.TryAdd("default", new ScopedContext());
_eventAggregator.GetEvent<LoginSuccessEvent>().Subscribe(() =>
{
@@ -457,6 +476,9 @@ namespace ADP.ViewModels
LoggerHelper.InfoWithNotify(runningScope, $"{_globalInfo.UserName} 执行工位 [{runningScope}] 复位命令");
// 重置导航栈,回到根程序界面
targetContext.ResetNavigation();
_executionTasks.TryGetValue(runningScope, out var currentTask);
_errorExecutionTasks.TryGetValue(runningScope, out var errorTask);
@@ -465,9 +487,22 @@ namespace ADP.ViewModels
targetRunner.stepCTS.Cancel();
targetRunner.errorStepCTS.Cancel();
await Task.Delay(200);
// 💡 等待旧任务真正结束,而不是只等 200ms
if (currentTask != null)
{
try { await currentTask; } catch { /* 取消导致的异常忽略 */ }
}
if (errorTask != null)
{
try { await errorTask; } catch { /* 取消导致的异常忽略 */ }
}
// 💡 彻底复位:替换 CTS、清理任务字典、停止计时器、归零状态
targetRunner.stepCTS = new CancellationTokenSource();
targetRunner.errorStepCTS = new CancellationTokenSource();
_executionTasks.TryRemove(runningScope, out _);
_errorExecutionTasks.TryRemove(runningScope, out _);
// 💡 彻底复位:停止当前工位的计时器,并且完全归零(Reset)
targetContext.SW.Reset();
targetContext.RunningTime = TimeSpan.Zero;
@@ -573,6 +608,7 @@ namespace ADP.ViewModels
targetContext.Program.Parameters.Clear();
targetContext.Program.StepCollection.Clear();
targetContext.Program.ErrorStepCollection.Clear();
targetContext.ResetNavigation();
foreach (var item in CurrentConfig.ParameterList)
{
var param = CurrentConfig.SharedParameterList.FirstOrDefault(x => x.ParameterName == item.Name);
+10
View File
@@ -226,6 +226,16 @@ Command="{Binding GetFileStringCommand}">
Foreground="Black" />
</MenuItem.Icon>
</MenuItem>
<Separator/>
<!-- 返回上一级程序 -->
<MenuItem Header="返回上一级程序"
Foreground="Black"
Command="{Binding GoBackCommand}">
<MenuItem.Icon>
<materialDesign:PackIcon Kind="ArrowLeftBold"
Foreground="Black" />
</MenuItem.Icon>
</MenuItem>
</MenuItem>
<MenuItem Header="切换九宫格"
FontSize="13"
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -1,164 +0,0 @@
using DeviceCommand.Base;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BenchMovementModule.HardwareDrive
{
public abstract class GantryControlBase
{
protected readonly IModbusDevice _device;
protected readonly byte _slaveAddress;
// 默认 PLC Modbus 映射地址
protected const ushort ADDR_HM_X_ENABLE = 0xC100; // HM0: X轴使能
protected const ushort ADDR_HM_Y_ENABLE = 0xC101; // HM1: Y轴使能
protected const ushort ADDR_HM_Z_ENABLE = 0xC102; // HM2: Z轴使能
protected const ushort ADDR_M_X_START = 30; // M30: X轴指定位置启动
protected const ushort ADDR_M_Y_START = 31; // M31: Y轴指定位置启动
protected const ushort ADDR_M_Z_START = 35; // M35: Z轴指定位置启动
protected const ushort ADDR_M_ALL_STOP = 40; // M40: 全停
protected const ushort ADDR_M_X_STOP = 41; // M41: X轴停止
protected const ushort ADDR_M_Y_STOP = 42; // M42: Y轴停止
protected const ushort ADDR_M_Z_STOP = 43; // M43: Z轴停止
protected const ushort ADDR_M_HOME = 100; // M100: 回原点触发
protected const ushort ADDR_HD_X_SPEED = 0xA166; // HD230: X轴速度 (32位)
protected const ushort ADDR_HD_Y_SPEED = 0xA17A; // HD250: Y轴速度 (32位)
protected const ushort ADDR_HD_Z_SPEED = 0xA170; // HD240: Z轴速度 (32位)
protected const ushort ADDR_HD_X_TARGET = 0xA10C; // HD140: X轴指定位置 (32位)
protected const ushort ADDR_HD_Y_TARGET = 0xA120; // HD160: Y轴指定位置 (32位)
protected const ushort ADDR_HD_Z_TARGET = 0xA116; // HD150: Z轴指定位置 (32位)
public bool IsConnected => _device?.IsConnected ?? false;
protected GantryControlBase(IModbusDevice device, byte slaveAddress = 1)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_slaveAddress = slaveAddress;
}
public async Task<bool> ConnectAsync(CancellationToken ct = default)
{
return await _device.ConnectAsync(ct);
}
public void Close()
{
_device.Close();
}
/// <summary>
/// 轴使能控制
/// </summary>
public async Task SetAxisEnableAsync(int axis, bool enable, CancellationToken ct = default)
{
ushort address = axis switch
{
1 => ADDR_HM_X_ENABLE, // X
2 => ADDR_HM_Y_ENABLE, // Y
3 => ADDR_HM_Z_ENABLE, // Z
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)")
};
await _device.WriteSingleCoilAsync(_slaveAddress, address, enable, ct);
}
/// <summary>
/// 左右移动(X轴)
/// </summary>
/// <param name="position">目标绝对位置(脉冲数/单位值)</param>
/// <param name="speed">运行速度</param>
public async Task MoveLeftRightAsync(int position, int speed, CancellationToken ct = default)
{
// 1. 写入速度与目标位置(32位数据,写入连续的2个保持寄存器)
await _device.WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_X_SPEED, Int32ToUshorts(speed), ct);
await _device.WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_X_TARGET, Int32ToUshorts(position), ct);
// 2. 边缘触发启动信号 M30
await TriggerCoilAsync(ADDR_M_X_START, ct);
}
/// <summary>
/// 上下移动(Z轴)
/// </summary>
/// <param name="position">目标绝对位置</param>
/// <param name="speed">运行速度</param>
public async Task MoveUpDownAsync(int position, int speed, CancellationToken ct = default)
{
// 1. 写入速度与目标位置(32位数据)
await _device.WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Z_SPEED, Int32ToUshorts(speed), ct);
await _device.WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Z_TARGET, Int32ToUshorts(position), ct);
// 2. 边缘触发启动信号 M35
await TriggerCoilAsync(ADDR_M_Z_START, ct);
}
/// <summary>
/// 前后移动(Y轴 - 预留拓展)
/// </summary>
public async Task MoveFrontBackAsync(int position, int speed, CancellationToken ct = default)
{
await _device.WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Y_SPEED, Int32ToUshorts(speed), ct);
await _device.WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Y_TARGET, Int32ToUshorts(position), ct);
await TriggerCoilAsync(ADDR_M_Y_START, ct);
}
/// <summary>
/// 全局回零
/// </summary>
public async Task HomeAsync(CancellationToken ct = default)
{
await TriggerCoilAsync(ADDR_M_HOME, ct);
}
/// <summary>
/// 轴停止
/// </summary>
/// <param name="axis">1:X轴, 2:Y轴, 3:Z轴, 0:全停</param>
public async Task StopAsync(int axis = 0, CancellationToken ct = default)
{
ushort address = axis switch
{
0 => ADDR_M_ALL_STOP,
1 => ADDR_M_X_STOP,
2 => ADDR_M_Y_STOP,
3 => ADDR_M_Z_STOP,
_ => ADDR_M_ALL_STOP
};
await TriggerCoilAsync(address, ct);
}
#region
/// <summary>
/// 触发一个点动信号(置 1 后,延时 100ms 自动置 0)
/// </summary>
protected async Task TriggerCoilAsync(ushort address, CancellationToken ct = default)
{
await _device.WriteSingleCoilAsync(_slaveAddress, address, true, ct);
await Task.Delay(100, ct); // 给予 PLC 扫描周期足够的响应时间
await _device.WriteSingleCoilAsync(_slaveAddress, address, false, ct);
}
/// <summary>
/// 将 32 位整型数据转换为 Modbus 的 2 个 16 位无符号整数(高低字转换)
/// </summary>
protected ushort[] Int32ToUshorts(int value)
{
// 本处采用低字在前(CDAB),如果PLC高字在前(ABCD),可将返回数组顺序颠倒
ushort lowWord = (ushort)(value & 0xFFFF);
ushort highWord = (ushort)((value >> 16) & 0xFFFF);
return new ushort[] { lowWord, highWord };
}
#endregion
}
}
@@ -1,28 +0,0 @@
using DeviceCommand.Base;
using System;
using System.Collections.Generic;
using System.IO.Ports;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BenchMovementModule.HardwareDrive
{
public class GantryControlRtu : GantryControlBase
{
// 暴露出底层的 ModbusRtu 实例以便修改串口配置
public ModbusRtu RtuDevice => (ModbusRtu)_device;
public GantryControlRtu(string portName, int baudRate = 9600, byte slaveAddress = 1)
: base(new ModbusRtu(), slaveAddress)
{
RtuDevice.ConfigureDevice(portName, baudRate);
}
public GantryControlRtu(string portName, int baudRate, int dataBits, StopBits stopBits, Parity parity, byte slaveAddress = 1)
: base(new ModbusRtu(), slaveAddress)
{
RtuDevice.ConfigureDevice(portName, baudRate, dataBits, stopBits, parity);
}
}
}
@@ -1,27 +0,0 @@
using DeviceCommand.Base;
using Model.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BenchMovementModule.HardwareDrive
{
public class GantryControlTcp : GantryControlBase
{
// 暴露出底层的 ModbusTcp 实例以便修改网络配置
public ModbusTcp TcpDevice => (ModbusTcp)_device;
public GantryControlTcp(TcpConfig config, byte slaveAddress = 1)
: base(new ModbusTcp(config), slaveAddress)
{
}
public GantryControlTcp(string ipAddress, int port = 502, byte slaveAddress = 1)
: base(new ModbusTcp(), slaveAddress)
{
TcpDevice.ConfigureDevice(ipAddress, port);
}
}
}
@@ -221,8 +221,8 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetAxisTargetPositionAsync(1, TargetX);
await _gantry.StartSingleAxisAsync(1);
await _gantry!.(1, TargetX);
await _gantry.(1);
LoggerHelper.Info($"[BenchMovement] X轴移动到 {TargetX}");
}
catch (Exception ex)
@@ -237,8 +237,8 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetAxisTargetPositionAsync(2, TargetY);
await _gantry.StartSingleAxisAsync(2);
await _gantry!.(2, TargetY);
await _gantry.(2);
LoggerHelper.Info($"[BenchMovement] Y轴移动到 {TargetY}");
}
catch (Exception ex)
@@ -253,8 +253,8 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetAxisTargetPositionAsync(3, TargetZ);
await _gantry.StartSingleAxisAsync(3);
await _gantry!.(3, TargetZ);
await _gantry.(3);
LoggerHelper.Info($"[BenchMovement] Z轴移动到 {TargetZ}");
}
catch (Exception ex)
@@ -269,10 +269,10 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetAxisTargetPositionAsync(1, TargetX);
await _gantry.SetAxisTargetPositionAsync(2, TargetY);
await _gantry.SetAxisTargetPositionAsync(3, TargetZ);
await _gantry.StartAllAxesAsync();
await _gantry!.(1, TargetX);
await _gantry.(2, TargetY);
await _gantry.(3, TargetZ);
await _gantry.();
LoggerHelper.Info($"[BenchMovement] 三轴同时移动: X={TargetX}, Y={TargetY}, Z={TargetZ}");
}
catch (Exception ex)
@@ -287,7 +287,7 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.HomeAsync();
await _gantry!.(15);
LoggerHelper.Info("[BenchMovement] 回原点已触发");
}
catch (Exception ex)
@@ -302,7 +302,7 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetEStopStateAsync(true);
await _gantry!.(true);
LoggerHelper.Info("[BenchMovement] 急停已触发");
}
catch (Exception ex)
@@ -316,9 +316,9 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetStopStateAsync(1, true);
await _gantry!.(1, true);
await Task.Delay(100);
await _gantry.SetStopStateAsync(1, false);
await _gantry.(1, false);
}
catch (Exception ex)
{
@@ -331,9 +331,9 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetStopStateAsync(2, true);
await _gantry!.(2, true);
await Task.Delay(100);
await _gantry.SetStopStateAsync(2, false);
await _gantry.(2, false);
}
catch (Exception ex)
{
@@ -346,9 +346,9 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetStopStateAsync(3, true);
await _gantry!.(3, true);
await Task.Delay(100);
await _gantry.SetStopStateAsync(3, false);
await _gantry.(3, false);
}
catch (Exception ex)
{
@@ -361,10 +361,10 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetAxisSpeedAsync(1, SpeedX);
await _gantry.SetAxisSpeedAsync(2, SpeedY);
await _gantry.SetAxisSpeedAsync(3, SpeedZ);
await _gantry.SetHomePositionSpeedAsync(HomeSpeed);
await _gantry!.(1, SpeedX);
await _gantry.(2, SpeedY);
await _gantry.(3, SpeedZ);
await _gantry.(HomeSpeed);
LoggerHelper.Info($"[BenchMovement] 速度设定完成: X={SpeedX}, Y={SpeedY}, Z={SpeedZ}, 回零={HomeSpeed}");
}
catch (Exception ex)
@@ -379,7 +379,7 @@ namespace BenchMovementModule.ViewModels
if (!EnsureConnected()) return;
try
{
await _gantry!.SetHomePositionAsync(HomeX, HomeY, HomeZ);
await _gantry!.(HomeX, HomeY, HomeZ);
LoggerHelper.Info($"[BenchMovement] 零点设定完成: X={HomeX}, Y={HomeY}, Z={HomeZ}");
}
catch (Exception ex)
@@ -419,16 +419,16 @@ namespace BenchMovementModule.ViewModels
if (_gantry == null || !_gantry.IsConnected) return;
try
{
var (x, y, z) = await _gantry.GetHomePositionAsync();
var (x, y, z) = await _gantry.();
HomeX = x; HomeY = y; HomeZ = z;
SpeedX = await _gantry.GetAxisSpeedAsync(1);
SpeedY = await _gantry.GetAxisSpeedAsync(2);
SpeedZ = await _gantry.GetAxisSpeedAsync(3);
HomeSpeed = await _gantry.GetHomePositionSpeedAsync();
SpeedX = await _gantry.(1);
SpeedY = await _gantry.(2);
SpeedZ = await _gantry.(3);
HomeSpeed = await _gantry.();
// 读取 6 轴绝对位置
var positions = await _gantry.GetAllAbsolutePositionsAsync();
var positions = await _gantry.();
AbsPos1 = positions.Axis1; AbsPos2 = positions.Axis2; AbsPos3 = positions.Axis3;
AbsPos4 = positions.Axis4; AbsPos5 = positions.Axis5; AbsPos6 = positions.Axis6;
}
@@ -444,7 +444,7 @@ namespace BenchMovementModule.ViewModels
try
{
// 批量读取 6 轴绝对位置(一次性通信,高效)
var pos = await _gantry.GetAllAbsolutePositionsAsync();
var pos = await _gantry.();
AbsPos1 = pos.Axis1; AbsPos2 = pos.Axis2; AbsPos3 = pos.Axis3;
AbsPos4 = pos.Axis4; AbsPos5 = pos.Axis5; AbsPos6 = pos.Axis6;
}
+32
View File
@@ -347,6 +347,7 @@ namespace CANModule.ViewModels
DeleteDcAutoLoadCommand = new DelegateCommand(DeleteDcAutoLoad);
SendCustomMessageCommand = new DelegateCommand(SendCustomMessage);
_canfd.OnDbcMessageDecoded += OnDbcMessageDecoded;
_canfd.OnRawMessageReceived += OnRawMessageReceived;
LoadCommand = new DelegateCommand(Load);
}
@@ -368,6 +369,7 @@ namespace CANModule.ViewModels
if (_canfd != null)
{
_canfd.OnDbcMessageDecoded -= OnDbcMessageDecoded;
_canfd.OnRawMessageReceived -= OnRawMessageReceived;
}
_refreshCts?.Cancel();
}
@@ -627,6 +629,36 @@ namespace CANModule.ViewModels
});
}
private void OnRawMessageReceived(uint channel, uint canId, byte[] data, ulong timestamp, bool isFD, byte dlcLength)
{
// 原始报文回调:不依赖 DBC,自定义报文也能收到
Application.Current.Dispatcher.BeginInvoke(() =>
{
// 提取实际 CAN ID(去掉扩展帧标志位)
int actualId = (int)(canId & 0x1FFFFFFF);
// 原始字节转为 [0x00, 0x01, ...] 格式,供监视表格直接显示
string hexData = (data);
var find = CanMessageList.FirstOrDefault(s => s.ID == actualId && s. == channel);
if (find != null)
{
find. = (ulong)DateTimeOffset.Now.ToUnixTimeMilliseconds();
find. = dlcLength;
find. = hexData;
}
else
{
CanMessageList.Add(new CanMessageShowVM
{
= (byte)channel,
ID = actualId,
= (ulong)DateTimeOffset.Now.ToUnixTimeMilliseconds(),
= dlcLength,
= hexData
});
}
});
}
private string (System.Span<byte> s)
{
if (s == null || s.Length == 0)
+5 -4
View File
@@ -79,16 +79,17 @@
<!-- 监视选项 -->
<TreeViewItem Header="监视">
<DataGrid x:Name="表格"
Width="550"
Width="Auto"
Height="350"
VirtualizingPanel.IsVirtualizing="True"
IsReadOnly="True"
AutoGenerateColumns="False"
ItemsSource="{Binding CanMessageList, Mode=OneWay}">
<DataGrid.Columns>
<DataGridTextColumn Header="通道" Binding="{Binding 通道}" Width="90" />
<DataGridTextColumn Header="报文ID" Binding="{Binding 报文ID, Converter={StaticResource HexConverter}}" Width="150" />
<DataGridTextColumn Header="长度" Binding="{Binding 长度}" Width="90" />
<DataGridTextColumn Header="通道" Binding="{Binding 通道}" Width="60" />
<DataGridTextColumn Header="报文ID" Binding="{Binding 报文ID, Converter={StaticResource HexConverter}}" Width="110" />
<DataGridTextColumn Header="长度" Binding="{Binding 长度}" Width="50" />
<DataGridTextColumn Header="数据" Binding="{Binding 数据}" Width="Auto" />
<DataGridTextColumn Header="时间戳" Binding="{Binding 时间戳}" Width="Auto" />
</DataGrid.Columns>
</DataGrid>
+1
View File
@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
+62
View File
@@ -0,0 +1,62 @@
using Common.Attributes;
using System;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
namespace Command
{
/// <summary>
/// 对话框命令:在测试流程中弹出提示窗口。
/// 命令库为纯 .NET 类库,不直接引用 WPF 程序集;
/// 实际的弹窗能力由宿主程序(ADP 外壳)在启动时注入到 <see cref="弹窗处理器"/> 委托中实现(依赖倒置)。
/// </summary>
[ADPCommand]
public static class CommandDialog
{
/// <summary>
/// 弹窗类型
/// </summary>
public enum DialogType
{
/// <summary>
/// 信息提示
/// </summary>
Info,
/// <summary>
/// 警告提示
/// </summary>
Warning,
/// <summary>
/// 错误提示
/// </summary>
Error
}
/// <summary>
/// 弹窗处理委托(参数依次为:弹窗类型、弹窗详细、是否阻塞、自动关闭秒数、取消令牌)。
/// 由宿主程序启动时注入实现;未注入时弹窗命令降级为仅输出日志,不会抛异常。
/// </summary>
[Browsable(false)]
public static Func<DialogType, string, bool, float, CancellationToken, Task> ;
/// <summary>
/// 弹窗:在界面上显示一个提示对话框。
/// </summary>
/// <param name="弹窗类型">弹窗样式:Info 信息 / Warning 警告 / Error 错误</param>
/// <param name="弹窗详细">弹窗中显示的详细内容</param>
/// <param name="是否阻塞">true = 步骤暂停,等待用户关闭(或自动关闭)后才继续;false = 弹出后步骤立即继续</param>
/// <param name="自动关闭秒数">大于 0 时,弹窗在指定秒数后自动关闭;小于等于 0 时不自动关闭,需用户手动关闭</param>
/// <param name="ct">异步取消令牌</param>
public static async Task (DialogType , string , bool , float , CancellationToken ct)
{
var handler = ;
if (handler == null)
{
Console.WriteLine($"[弹窗命令] 宿主未注入弹窗处理器,跳过弹窗:{弹窗详细}");
return;
}
await handler(, , , , ct);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
using Common.Attributes;
using Common.Tools;
using System;
namespace Command
{
/// <summary>
/// 步骤运行耗时统计命令。
/// <para>
/// 命令库为纯 .NET 类库,不直接引用宿主的步骤管理器类型;
/// 实际的步骤数据查询能力由宿主(StepRunning)在执行测试步骤前通过
/// <see cref="Common.Tools.StepRuntimeContext.StepTimeQuery"/> 注入(依赖倒置),
/// 多台架并发运行时各作用域的查询器随异步流隔离,互不串扰。
/// </para>
/// </summary>
[ADPCommand]
public static class CommandRunningTime
{
/// <summary>
/// 计算测试步骤运行时间之和:统计步骤管理器主程序中,
/// 序号从起始序号到结束序号(含两端)范围内所有步骤的运行耗时总和。
/// </summary>
/// <param name="起始序号">起始步骤序号(从 1 开始)</param>
/// <param name="结束序号">结束步骤序号(含本身)</param>
/// <returns>范围内步骤运行时间之和(毫秒);序号超界、范围内无步骤或查询异常时输出 0</returns>
public static double (int , int )
{
// 读取当前异步执行流注入的步骤耗时查询器(由 StepRunning 设置)
var query = StepRuntimeContext.StepTimeQuery.Value;
if (query == null)
{
Console.WriteLine($"[步骤耗时] 宿主未注入步骤耗时查询器,无法计算序号 {起始序号}~{结束序号} 的运行时间之和");
return 0;
}
try
{
return query(, );
}
catch (Exception ex)
{
Console.WriteLine($"[步骤耗时] 计算序号 {起始序号}~{结束序号} 的运行时间之和失败: {ex.Message}");
return 0;
}
}
}
}
+1 -1
View File
@@ -4,12 +4,12 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ncalc" Version="1.3.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.5-beta1" />
<PackageReference Include="System.IO.Ports" Version="11.0.0-preview.4.26230.115" />
</ItemGroup>
</Project>
+25 -1
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
@@ -23,7 +24,7 @@ namespace Common.Tools
{
foreach (var kvp in processedVariables)
{
expr.Parameters[kvp.Key] = kvp.Value;
expr.Parameters[kvp.Key] = NormalizeValue(kvp.Value);
}
}
@@ -95,6 +96,29 @@ namespace Common.Tools
return (processedExpression, newVariables);
}
/// <summary>
/// 归一化变量取值:输入变量的值常以字符串形式保存(如 "10"),
/// 若直接参与比较,NCalc 会按字符串逐位比较("10" > "5" 为假),
/// 导致大于/小于判断失真。此处将可解析为数字/布尔的字符串转换为对应类型,
/// 使比较按数值语义进行;无法解析的字符串保持原样。
/// </summary>
private static object? NormalizeValue(object? value)
{
if (value is string s)
{
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)
|| double.TryParse(s, out d))
{
return d;
}
if (bool.TryParse(s, out var b))
{
return b;
}
}
return value;
}
// 检查字符串是否包含中文字符
private static bool ContainsChinese(string text)
{
+27
View File
@@ -0,0 +1,27 @@
using System;
using System.ComponentModel;
namespace Common.Tools
{
/// <summary>
/// 步骤运行时上下文桥接器(命令库与宿主之间的依赖倒置通道)。
/// <para>
/// 命令库(Command 项目)不引用宿主类型,无法直接访问步骤管理器的数据;
/// 宿主(StepRunning)在执行测试步骤前,将当前作用域的步骤耗时查询委托写入
/// <see cref="StepTimeQuery"/>;命令库中的耗时统计命令在执行时读取该委托完成查询。
/// </para>
/// <para>
/// 使用 <see cref="AsyncLocal{T}"/> 承载:委托随异步执行流(ExecutionContext)流动,
/// 多台架并发运行时各自持有自己作用域的查询器,天然隔离互不串扰。
/// </para>
/// </summary>
public static class StepRuntimeContext
{
/// <summary>
/// 当前异步执行流的步骤耗时查询委托:(起始序号, 结束序号) → 该范围内步骤运行时间之和(毫秒)。
/// 由宿主(StepRunning)在每次执行测试步骤方法前设置;未设置时命令侧按未注入处理。
/// </summary>
[Browsable(false)]
public static readonly AsyncLocal<Func<int, int, double>?> StepTimeQuery = new();
}
}
+2 -2
View File
@@ -12,8 +12,8 @@ namespace DeviceCommand.Base
{
public string IPAddress { get; private set; } = "127.0.0.1";
public int Port { get; private set; } = 502;
public int SendTimeout { get; private set; } = 3000;
public int ReceiveTimeout { get; private set; } = 3000;
public int SendTimeout { get; private set; } = 10000;
public int ReceiveTimeout { get; private set; } = 10000;
private TcpClient _tcpClient;
public IModbusMaster Modbus { get; private set; }
+1
View File
@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
+326 -129
View File
@@ -7,6 +7,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
using System.ComponentModel;
namespace DeviceCommand.Devices
{
@@ -18,6 +19,14 @@ namespace DeviceCommand.Devices
// 向后兼容:TcpDevice 即自身(继承自 ModbusTcp
public ModbusTcp TcpDevice => this;
// =================================================================
// ------------------ 软件位置跟踪(绝对位置寄存器会被清零)----------
// =================================================================
/// <summary>当前位置相对于机械零点的位置(每次移动后累加偏移)</summary>
private int _originX, _originY, _originZ;
/// <summary>是否已完成回零初始化</summary>
private bool _isHomed;
public GantryControlTcp(TcpConfig config) : base(config)
{
@@ -26,7 +35,7 @@ namespace DeviceCommand.Devices
// =================================================================
// ------------------ Coil / Bit 状态与控制 (位元件) ------------------
// =================================================================
private const ushort ADDR_M_ESTOP = 20496; // 0x5010: 紧停
private const ushort ADDR_M_ESTOP = 40; // 0x5010: 紧停
private const ushort ADDR_M_X_STOP = 41; // 0x29: X轴停止
private const ushort ADDR_M_Y_STOP = 42; // 0x2A: Y轴停止
private const ushort ADDR_M_Z_STOP = 43; // 0x2B: Z轴停止
@@ -84,10 +93,12 @@ namespace DeviceCommand.Devices
/// <summary>
/// 轴使能控制
/// </summary>
/// <param name="axis">轴编号 (1 - 6)</param>
public async Task SetAxisEnableAsync(int axis, bool enable, CancellationToken ct = default)
/// <param name="轴编号">轴编号 (1 - 6)</param>
/// <param name="使能">true: 使能轴, false: 禁用轴</param>
/// <param name="取消令牌">取消令牌</param>
public async Task 使(int , bool 使, CancellationToken = default)
{
ushort address = axis switch
ushort address = switch
{
1 => ADDR_HM_AXIS1_ENABLE,
2 => ADDR_HM_AXIS2_ENABLE,
@@ -95,60 +106,64 @@ namespace DeviceCommand.Devices
4 => ADDR_HM_AXIS4_ENABLE,
5 => ADDR_HM_AXIS5_ENABLE,
6 => ADDR_HM_AXIS6_ENABLE,
_ => throw new ArgumentException("轴通道错误,仅支持 1 - 6", nameof(axis))
_ => throw new ArgumentException("轴通道错误,仅支持 1 - 6", nameof())
};
await WriteSingleCoilAsync(_slaveAddress, address, enable, ct);
await WriteSingleCoilAsync(_slaveAddress, address, 使, );
}
/// <summary>
/// 读取指定轴的使能状态
/// </summary>
/// <param name="axis">1: X轴, 2: Y轴, 3: Z轴</param>
public async Task<bool> GetAxisEnableAsync(int axis, CancellationToken ct = default)
/// <param name="轴编号">1: X轴, 2: Y轴, 3: Z轴</param>
/// <param name="取消令牌">取消令牌</param>
/// <returns>true: 已使能, false: 未使能</returns>
public async Task<bool> 使(int , CancellationToken = default)
{
ushort address = axis switch
ushort address = switch
{
1 => ADDR_HM_AXIS1_ENABLE,
2 => ADDR_HM_AXIS2_ENABLE,
3 => ADDR_HM_AXIS3_ENABLE,
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof(axis))
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof())
};
bool[] coils = await ReadCoilsAsync(_slaveAddress, address, 1, ct);
bool[] coils = await ReadCoilsAsync(_slaveAddress, address, 1, );
return coils != null && coils.Length > 0 && coils[0];
}
/// <summary>
/// 同时设置 X/Y/Z 轴的原点位置(带各自独立的范围检查)
/// </summary>
/// <param name="xPos">X轴原点位置 (范围: 0 ~ 50000)</param>
/// <param name="yPos">Y轴原点位置 (范围: 0 ~ 50000)</param>
/// <param name="zPos">Z轴原点位置 (范围: 0 ~ 20000)</param>
public async Task SetHomePositionAsync(int xPos, int yPos, int zPos, CancellationToken ct = default)
/// <param name="X轴原点">X轴原点位置 (范围: 0 ~ 50000)</param>
/// <param name="Y轴原点">Y轴原点位置 (范围: 0 ~ 50000)</param>
/// <param name="Z轴原点">Z轴原点位置 (范围: 0 ~ 40000)</param>
/// <param name="取消令牌">取消令牌</param>
public async Task (int X轴原点, int Y轴原点, int Z轴原点, CancellationToken = default)
{
if (xPos < 0 || xPos > 50000)
throw new ArgumentOutOfRangeException(nameof(xPos), $"X轴原点设置值 [{xPos}] 超出合法范围 (0 - 50000)");
if (X轴原点 < 0 || X轴原点 > 50000)
throw new ArgumentOutOfRangeException(nameof(X轴原点), $"X轴原点设置值 [{X轴原点}] 超出合法范围 (0 - 50000)");
if (yPos < 0 || yPos > 50000)
throw new ArgumentOutOfRangeException(nameof(yPos), $"Y轴原点设置值 [{yPos}] 超出合法范围 (0 - 50000)");
if (Y轴原点 < 0 || Y轴原点 > 50000)
throw new ArgumentOutOfRangeException(nameof(Y轴原点), $"Y轴原点设置值 [{Y轴原点}] 超出合法范围 (0 - 50000)");
if (zPos < 0 || zPos > 20000)
throw new ArgumentOutOfRangeException(nameof(zPos), $"Z轴原点设置值 [{zPos}] 超出合法范围 (0 - 20000)");
if (Z轴原点 < 0 || Z轴原点 > 40000)
throw new ArgumentOutOfRangeException(nameof(Z轴原点), $"Z轴原点设置值 [{Z轴原点}] 超出合法范围 (0 - 40000)");
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_X_HOME_POS, Int32ToUshorts(xPos), ct);
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Y_HOME_POS, Int32ToUshorts(yPos), ct);
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Z_HOME_POS, Int32ToUshorts(zPos), ct);
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_X_HOME_POS, Int32ToUshorts(X轴原点), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Y_HOME_POS, Int32ToUshorts(Y轴原点), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Z_HOME_POS, Int32ToUshorts(Z轴原点), );
}
/// <summary>
/// 批量查询当前 X、Y、Z 三轴设定的原点位置值
/// </summary>
/// <param name="取消令牌">取消令牌</param>
/// <returns>返回包含三轴原点位置的元组 (X, Y, Z)</returns>
public async Task<(int X, int Y, int Z)> GetHomePositionAsync(CancellationToken ct = default)
public async Task<(int X, int Y, int Z)> (CancellationToken = default)
{
// 41218(Y) - 41198(X) = 20,加Y轴自身的 2 个寄存器,共批量读取 22 个连续寄存器
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, ADDR_HD_X_HOME_POS, 22, ct);
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, ADDR_HD_X_HOME_POS, 22, );
if (registers == null || registers.Length < 22)
throw new InvalidOperationException("从 PLC 读取三轴原点位置失败,返回数据长度不足。");
@@ -163,20 +178,24 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置回原点定位速度(带 0 - 100000 范围检查)
/// </summary>
public async Task SetHomePositionSpeedAsync(int speed, CancellationToken ct = default)
/// <param name="速度">回原点定位速度 (范围: 0 - 100000)</param>
/// <param name="取消令牌">取消令牌</param>
public async Task (int , CancellationToken = default)
{
if (speed < 0 || speed > 100000)
throw new ArgumentOutOfRangeException(nameof(speed), $"回原点定位速度值 [{speed}] 超出合法范围 (0 - 100000)");
if ( < 0 || > 100000)
throw new ArgumentOutOfRangeException(nameof(), $"回原点定位速度值 [{速度}] 超出合法范围 (0 - 100000)");
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_HOME_POS_SPEED, Int32ToUshorts(speed), ct);
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_HOME_POS_SPEED, Int32ToUshorts(), );
}
/// <summary>
/// 查询当前设定的回原点定位速度
/// </summary>
public async Task<int> GetHomePositionSpeedAsync(CancellationToken ct = default)
/// <param name="取消令牌">取消令牌</param>
/// <returns>当前回原点定位速度值</returns>
public async Task<int> (CancellationToken = default)
{
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, ADDR_HD_HOME_POS_SPEED, 2, ct);
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, ADDR_HD_HOME_POS_SPEED, 2, );
if (registers == null || registers.Length < 2)
throw new InvalidOperationException("从 PLC 读取回原点定位速度失败,返回数据长度不足。");
@@ -187,39 +206,44 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置指定轴的运行速度(带 0 - 100000 范围检查)
/// </summary>
/// <param name="axis">1: X轴, 2: Y轴, 3: Z轴</param>
public async Task SetAxisSpeedAsync(int axis, int speed, CancellationToken ct = default)
/// <param name="轴编号">1: X轴, 2: Y轴, 3: Z轴</param>
/// <param name="速度">运行速度 (范围: 0 - 100000)</param>
/// <param name="取消令牌">取消令牌</param>
public async Task (int , int , CancellationToken = default)
{
if (speed < 0 || speed > 100000)
throw new ArgumentOutOfRangeException(nameof(speed), $"轴速度设置值 [{speed}] 超出合法范围 (0 - 100000)");
if ( < 0 || > 100000)
throw new ArgumentOutOfRangeException(nameof(), $"轴速度设置值 [{速度}] 超出合法范围 (0 - 100000)");
ushort address = axis switch
ushort address = switch
{
1 => ADDR_HD_X_SPEED,
2 => ADDR_HD_Y_SPEED,
3 => ADDR_HD_Z_SPEED,
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof(axis))
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof())
};
await WriteMultipleRegistersAsync(_slaveAddress, address, Int32ToUshorts(speed), ct);
await WriteMultipleRegistersAsync(_slaveAddress, address, Int32ToUshorts(), );
}
/// <summary>
/// 读取指定轴当前设定的运行速度
/// </summary>
public async Task<int> GetAxisSpeedAsync(int axis, CancellationToken ct = default)
/// <param name="轴编号">1: X轴, 2: Y轴, 3: Z轴</param>
/// <param name="取消令牌">取消令牌</param>
/// <returns>当前设定的运行速度值</returns>
public async Task<int> (int , CancellationToken = default)
{
ushort address = axis switch
ushort address = switch
{
1 => ADDR_HD_X_SPEED,
2 => ADDR_HD_Y_SPEED,
3 => ADDR_HD_Z_SPEED,
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof(axis))
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof())
};
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, address, 2, ct);
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, address, 2, );
if (registers == null || registers.Length < 2)
throw new InvalidOperationException($"从 PLC 读取轴 {axis} 速度失败。");
throw new InvalidOperationException($"从 PLC 读取轴 {轴编号} 速度失败。");
return UshortsToInt32(registers);
}
@@ -227,15 +251,18 @@ namespace DeviceCommand.Devices
/// <summary>
/// 写入指定轴的目标位置(动态安全校验:目标位置不能超过 [轴最大物理极限 - 当前轴原点设置值])
/// </summary>
public async Task SetAxisTargetPositionAsync(int axis, int targetPos, CancellationToken ct = default)
/// <param name="轴编号">1: X轴, 2: Y轴, 3: Z轴</param>
/// <param name="目标位置">目标位置</param>
/// <param name="取消令牌">取消令牌</param>
public async Task (int , int , CancellationToken = default)
{
var (xHome, yHome, zHome) = await GetHomePositionAsync(ct);
var (xHome, yHome, zHome) = await ();
ushort targetAddress;
int maxLimit;
int currentHomePos;
switch (axis)
switch ()
{
case 1:
targetAddress = ADDR_HD_X_TARGET;
@@ -249,43 +276,46 @@ namespace DeviceCommand.Devices
break;
case 3:
targetAddress = ADDR_HD_Z_TARGET;
maxLimit = 20000;
maxLimit = 40000;
currentHomePos = zHome;
break;
default:
throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof(axis));
throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof());
}
int allowedMaxLimit = maxLimit - currentHomePos;
if (targetPos < 0 || targetPos > allowedMaxLimit)
if ( < 0 || > allowedMaxLimit)
{
throw new ArgumentOutOfRangeException(
nameof(targetPos),
$"轴 {axis} 目标位置 [{targetPos}] 不合法!当前原点为 [{currentHomePos}],允许的有效写入范围为: 0 - {allowedMaxLimit}"
nameof(),
$"轴 {轴编号} 目标位置 [{目标位置}] 不合法!当前原点为 [{currentHomePos}],允许的有效写入范围为: 0 - {allowedMaxLimit}"
);
}
await WriteMultipleRegistersAsync(_slaveAddress, targetAddress, Int32ToUshorts(targetPos), ct);
await WriteMultipleRegistersAsync(_slaveAddress, targetAddress, Int32ToUshorts(), );
}
/// <summary>
/// 读取指定轴当前设定的目标指定位置
/// </summary>
public async Task<int> GetAxisTargetPositionAsync(int axis, CancellationToken ct = default)
/// <param name="轴编号">1: X轴, 2: Y轴, 3: Z轴</param>
/// <param name="取消令牌">取消令牌</param>
/// <returns>当前设定的目标位置值</returns>
public async Task<int> (int , CancellationToken = default)
{
ushort address = axis switch
ushort address = switch
{
1 => ADDR_HD_X_TARGET,
2 => ADDR_HD_Y_TARGET,
3 => ADDR_HD_Z_TARGET,
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof(axis))
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof())
};
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, address, 2, ct);
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, address, 2, );
if (registers == null || registers.Length < 2)
throw new InvalidOperationException($"从 PLC 读取轴 {axis} 目标位置失败。");
throw new InvalidOperationException($"从 PLC 读取轴 {轴编号} 目标位置失败。");
return UshortsToInt32(registers);
}
@@ -293,53 +323,101 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置系统紧停锁定状态 (true: 锁定激活, false: 解除锁定)
/// </summary>
public async Task SetEStopStateAsync(bool state, CancellationToken ct = default)
/// <param name="状态">true: 锁定激活, false: 解除锁定</param>
/// <param name="取消令牌">取消令牌</param>
public async Task (bool , CancellationToken = default)
{
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_ESTOP, state, ct);
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_ESTOP, , );
}
/// <summary>
/// 设置指定轴的停止控制状态 (true: 持续输出停止信号, false: 释放停止信号)
/// </summary>
public async Task SetStopStateAsync(int axis, bool state, CancellationToken ct = default)
/// <param name="轴编号">1: X轴, 2: Y轴, 3: Z轴</param>
/// <param name="状态">true: 持续输出停止信号, false: 释放停止信号</param>
/// <param name="取消令牌">取消令牌</param>
public async Task (int , bool , CancellationToken = default)
{
ushort address = axis switch
ushort address = switch
{
1 => ADDR_M_X_STOP,
2 => ADDR_M_Y_STOP,
3 => ADDR_M_Z_STOP,
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof(axis))
_ => throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof())
};
await WriteSingleCoilAsync(_slaveAddress, address, state, ct);
await WriteSingleCoilAsync(_slaveAddress, address, , );
}
/// <summary>
/// 全局回零(安全校验版:先检查回原点速度是否为 0,为0则拒绝触发)
/// 全局回零(安全校验版:先检查回原点速度是否为 0,为0则拒绝触发)
/// 回零完成后读取绝对位置寄存器保存为初始值,重置累计偏移。
/// </summary>
public async Task HomeAsync(CancellationToken ct = default)
/// <param name="回零点等待时间">回零点等待时间</param>
/// <param name="取消令牌">取消令牌</param>
public async Task (int , CancellationToken = default)
{
int currentSpeed = await GetHomePositionSpeedAsync(ct);
int currentSpeed = await ();
if (currentSpeed == 0)
throw new InvalidOperationException("无法触发回原点!当前回原点定位速度为 0,请先设置合法的回零速度。");
await TriggerCoilAsync(ADDR_M_HOME_TRIGGER, ct);
// 1. 触发回零
await TriggerCoilAsync(ADDR_M_HOME_TRIGGER, );
// 2. 等待到达机械零点(绝对位置 = 0)
await (, );
await Task.Delay(500, );
// 3. 清零绝对位置寄存器
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_ABS_AXIS1_POS, Int32ToUshorts(0), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_ABS_AXIS2_POS, Int32ToUshorts(0), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_ABS_AXIS3_POS, Int32ToUshorts(0), );
// 4. 设置当前位置为机械零点 (0, 0, 0)
// 设置目标位置
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_X_TARGET, Int32ToUshorts(0), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Y_TARGET, Int32ToUshorts(0), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Z_TARGET, Int32ToUshorts(0), );
// 5. 读取配置的待定位置(原点位置)
var (xHome, yHome, zHome) = await ();
// 6. 移动到待定位置
if (xHome != 0 || yHome != 0 || zHome != 0)
{
_originX = xHome;
_originY = yHome;
_originZ = zHome;
// 启动三轴联动
await ();
}
// 8. 更新当前位置为待定位置
_originX = xHome;
_originY = yHome;
_originZ = zHome;
_isHomed = true;
}
/// <summary>
/// 单轴指定位置启动(含速度判定、使能自动补齐、动作目标位置独立范围校验)
/// </summary>
/// <param name="axis">1: X轴, 2: Y轴, 3: Z轴</param>
public async Task StartSingleAxisAsync(int axis, CancellationToken ct = default)
/// <param name="轴编号">1: X轴, 2: Y轴, 3: Z轴</param>
/// <param name="取消令牌">取消令牌</param>
public async Task (int , CancellationToken = default)
{
ushort startAddress;
int maxLimit;
int currentHomePos;
// 1. 获取基础参数与校验边界
var (xHome, yHome, zHome) = await GetHomePositionAsync(ct);
switch (axis)
var (xHome, yHome, zHome) = await ();
switch ()
{
case 1:
startAddress = ADDR_M_X_START;
@@ -353,53 +431,66 @@ namespace DeviceCommand.Devices
break;
case 3:
startAddress = ADDR_M_Z_START;
maxLimit = 20000;
maxLimit = 40000;
currentHomePos = zHome;
break;
default:
throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof(axis));
throw new ArgumentException("轴通道错误,仅支持 1(X), 2(Y), 3(Z)", nameof());
}
// 2. 检查动作轴的速度
int currentSpeed = await GetAxisSpeedAsync(axis, ct);
int currentSpeed = await (, );
if (currentSpeed == 0)
throw new InvalidOperationException($"轴 {axis} 无法启动!当前设定速度为 0。");
throw new InvalidOperationException($"轴 {轴编号} 无法启动!当前设定速度为 0。");
// 3. 检查动作轴使能,若为 false 则自动补写 true 激活驱动器
bool isEnabled = await GetAxisEnableAsync(axis, ct);
if (!isEnabled)
// 3. 批量检查 1~6 号轴使能状态,若任意轴未使能则全部补写 true
bool[] allEnableStates = await ReadCoilsAsync(_slaveAddress, ADDR_HM_AXIS1_ENABLE, 6, );
if (allEnableStates == null || allEnableStates.Length < 6)
throw new InvalidOperationException("从 PLC 读取 1~6 号轴使能状态失败。");
bool hasModifiedEnable = false;
ushort[] enableAddresses = { ADDR_HM_AXIS1_ENABLE, ADDR_HM_AXIS2_ENABLE, ADDR_HM_AXIS3_ENABLE, ADDR_HM_AXIS4_ENABLE, ADDR_HM_AXIS5_ENABLE, ADDR_HM_AXIS6_ENABLE };
for (int i = 0; i < 6; i++)
{
await SetAxisEnableAsync(axis, true, ct);
await Task.Delay(100, ct); // 等待硬件继电器响应与伺服驱动就绪
if (!allEnableStates[i])
{
await WriteSingleCoilAsync(_slaveAddress, enableAddresses[i], true, );
hasModifiedEnable = true;
}
}
if (hasModifiedEnable)
await Task.Delay(100, ); // 等待硬件继电器响应与伺服驱动就绪
// 4. 精准独立校验当前需要动作轴的目标定位是否越界
int currentTarget = await GetAxisTargetPositionAsync(axis, ct);
int currentTarget = await (, );
int allowedMaxLimit = maxLimit - currentHomePos;
if (currentTarget < 0 || currentTarget > allowedMaxLimit)
{
throw new ArgumentOutOfRangeException($"轴 {axis} 无法启动!当前指定目标位置 [{currentTarget}] 超过了允许的最大安全极限值 [{allowedMaxLimit}]。");
throw new ArgumentOutOfRangeException($"轴 {轴编号} 无法启动!当前指定目标位置 [{currentTarget}] 超过了允许的最大安全极限值 [{allowedMaxLimit}]。");
}
// 5. 校验完成,边缘触发点动启动
await TriggerCoilAsync(startAddress, ct);
await TriggerCoilAsync(startAddress, );
}
/// <summary>
/// 三轴同时启动(全开:严格校验全部运行轴速度、批量检测与补正1~6号轴使能、联动校验三轴目标范围极限)
/// </summary>
public async Task StartAllAxesAsync(CancellationToken ct = default)
/// <param name="取消令牌">取消令牌</param>
public async Task (CancellationToken = default)
{
// 1. 一次性读取三轴速度,严禁任何一轴速度为 0
int xSpeed = await GetAxisSpeedAsync(1, ct);
int ySpeed = await GetAxisSpeedAsync(2, ct);
int zSpeed = await GetAxisSpeedAsync(3, ct);
int xSpeed = await (1, );
int ySpeed = await (2, );
int zSpeed = await (3, );
if (xSpeed == 0 || ySpeed == 0 || zSpeed == 0)
throw new InvalidOperationException($"无法全开启动!存在运行速度为 0 的轴。当前速度 -> X:{xSpeed}, Y:{ySpeed}, Z:{zSpeed}");
// 2. 批量读取 1 ~ 6 号轴连续的使能状态线圈,极大地优化网络性能
bool[] enableStates = await ReadCoilsAsync(_slaveAddress, ADDR_HM_AXIS1_ENABLE, 6, ct);
bool[] enableStates = await ReadCoilsAsync(_slaveAddress, ADDR_HM_AXIS1_ENABLE, 6, );
if (enableStates == null || enableStates.Length < 6)
throw new InvalidOperationException("从 PLC 读取 1~6 号轴使能状态失败。");
@@ -410,49 +501,94 @@ namespace DeviceCommand.Devices
{
if (!enableStates[i])
{
await WriteSingleCoilAsync(_slaveAddress, enableAddresses[i], true, ct);
await WriteSingleCoilAsync(_slaveAddress, enableAddresses[i], true, );
hasModifiedEnable = true;
}
}
// 如果执行过使能补齐补齐写入,在循环外进行统一延时,保证PLC时序安全
if (hasModifiedEnable)
await Task.Delay(100, ct);
await Task.Delay(100, );
// 3. 获取三轴原点及当前设定的目标指定位置
var (xHome, yHome, zHome) = await GetHomePositionAsync(ct);
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_X_START, true, );
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_Y_START, true, );
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_Z_START, true, );
int xTarget = await GetAxisTargetPositionAsync(1, ct);
int yTarget = await GetAxisTargetPositionAsync(2, ct);
int zTarget = await GetAxisTargetPositionAsync(3, ct);
await Task.Delay(100, ); // 保持高电平状态以适配 PLC 扫描周期
// X轴安全范围联动校验
int xMaxAllowed = 50000 - xHome;
if (xTarget < 0 || xTarget > xMaxAllowed)
throw new ArgumentOutOfRangeException(nameof(xTarget), $"X轴目标位置 [{xTarget}] 越界,当前原点下最大范围 0-{xMaxAllowed}");
// Y轴安全范围联动校验
int yMaxAllowed = 50000 - yHome;
if (yTarget < 0 || yTarget > yMaxAllowed)
throw new ArgumentOutOfRangeException(nameof(yTarget), $"Y轴目标位置 [{yTarget}] 越界,当前原点下最大范围 0-{yMaxAllowed}");
// Z轴安全范围联动校验
int zMaxAllowed = 20000 - zHome;
if (zTarget < 0 || zTarget > zMaxAllowed)
throw new ArgumentOutOfRangeException(nameof(zTarget), $"Z轴目标位置 [{zTarget}] 越界,当前原点下最大范围 0-{zMaxAllowed}");
// 4. 全部联动校验通过,三轴点动同步启动
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_X_START, true, ct);
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_Y_START, true, ct);
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_Z_START, true, ct);
await Task.Delay(100, ct); // 保持高电平状态以适配 PLC 扫描周期
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_X_START, false, ct);
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_Y_START, false, ct);
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_Z_START, false, ct);
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_X_START, false, );
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_Y_START, false, );
await WriteSingleCoilAsync(_slaveAddress, ADDR_M_Z_START, false, );
}
/// <summary>
/// 三轴相对移动:以(初始绝对值 + 累计偏移 + 本次偏移)做越界校验,
/// 清零绝对位置寄存器后将偏移量写入目标寄存器,启动三轴联动,
/// 等待到达后清零目标寄存器并更新累计偏移。偏移量可为负值。
/// 调用前必须先执行回零()完成初始化。
/// </summary>
/// <param name="X偏移量">X轴相对偏移量(可为负)</param>
/// <param name="Y偏移量">Y轴相对偏移量(可为负)</param>
/// <param name="Z偏移量">Z轴相对偏移量(可为负)</param>
/// <param name="超时秒数">等待到达目标位置的最大超时时间(秒)</param>
/// <param name="取消令牌">取消令牌</param>
public async Task (int X偏移量, int Y偏移量, int Z偏移量, int = 60, CancellationToken = default)
{
if (!_isHomed)
throw new InvalidOperationException("尚未完成回零初始化,请先调用 回零() 方法。");
// 1. 越界校验:当前位置 + 本次偏移是否在合法范围
int targetAbsX = _originX + X偏移量;
int targetAbsY = _originY + Y偏移量;
int targetAbsZ = _originZ + Z偏移量;
if (targetAbsX > 50000)
throw new ArgumentOutOfRangeException(nameof(X偏移量),
$"X轴越界:当前 {_originX} + 偏移 {X偏移量} = {targetAbsX},最大范围 50000");
if (targetAbsY > 50000)
throw new ArgumentOutOfRangeException(nameof(Y偏移量),
$"Y轴越界:当前 {_originY} + 偏移 {Y偏移量} = {targetAbsY},最大范围 50000");
if (targetAbsZ > 40000)
throw new ArgumentOutOfRangeException(nameof(Z偏移量),
$"Z轴越界:当前 {_originZ} + 偏移 {Z偏移量} = {targetAbsZ},最大范围 40000");
// 2. 将偏移量写入目标位置寄存器(ABS已在上次移动后清零,目标 = 偏移量)
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_X_TARGET, Int32ToUshorts(X偏移量), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Y_TARGET, Int32ToUshorts(Y偏移量), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_HD_Z_TARGET, Int32ToUshorts(Z偏移量), );
// 3. 启动三轴联动
await ();
bool IsArrived= await (,);
if (IsArrived)
{
// 5. 到达后更新当前位置(累加偏移)
_originX += X偏移量;
_originY += Y偏移量;
_originZ += Z偏移量;
await ();
}
else
{
var pos = await ();
_originX += pos.Axis1;
_originY += pos.Axis6;
_originZ += pos.Axis2;
await ();
}
}
public async Task ( CancellationToken = default)
{
// 6. 清零 6 轴绝对位置寄存器
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_ABS_AXIS1_POS, Int32ToUshorts(0), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_ABS_AXIS2_POS, Int32ToUshorts(0), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_ABS_AXIS3_POS, Int32ToUshorts(0), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_ABS_AXIS4_POS, Int32ToUshorts(0), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_ABS_AXIS5_POS, Int32ToUshorts(0), );
await WriteMultipleRegistersAsync(_slaveAddress, ADDR_ABS_AXIS6_POS, Int32ToUshorts(0), );
}
#region
/// <summary>
@@ -492,10 +628,12 @@ namespace DeviceCommand.Devices
/// <summary>
/// 读取指定轴的绝对位置(相对原点的实时位置,只读)
/// </summary>
/// <param name="axis">轴编号 (1-6)</param>
public async Task<int> GetAbsolutePositionAsync(int axis, CancellationToken ct = default)
/// <param name="轴编号">轴编号 (1-6)</param>
/// <param name="取消令牌">取消令牌</param>
/// <returns>指定轴的绝对位置值</returns>
public async Task<int> (int , CancellationToken = default)
{
ushort address = axis switch
ushort address = switch
{
1 => ADDR_ABS_AXIS1_POS,
2 => ADDR_ABS_AXIS2_POS,
@@ -503,12 +641,12 @@ namespace DeviceCommand.Devices
4 => ADDR_ABS_AXIS4_POS,
5 => ADDR_ABS_AXIS5_POS,
6 => ADDR_ABS_AXIS6_POS,
_ => throw new ArgumentException("轴编号错误,仅支持 1-6", nameof(axis))
_ => throw new ArgumentException("轴编号错误,仅支持 1-6", nameof())
};
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, address, 2, ct);
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, address, 2, );
if (registers == null || registers.Length < 2)
throw new InvalidOperationException($"读取轴 {axis} 绝对位置失败。");
throw new InvalidOperationException($"读取轴 {轴编号} 绝对位置失败。");
return UshortsToInt32(registers);
}
@@ -516,11 +654,12 @@ namespace DeviceCommand.Devices
/// <summary>
/// 批量读取 1-6 轴的绝对位置(一次性读取 22 个寄存器,高效)
/// </summary>
/// <param name="取消令牌">取消令牌</param>
/// <returns>返回 6 轴绝对位置元组</returns>
public async Task<(int Axis1, int Axis2, int Axis3, int Axis4, int Axis5, int Axis6)> GetAllAbsolutePositionsAsync(CancellationToken ct = default)
public async Task<(int Axis1, int Axis2, int Axis3, int Axis4, int Axis5, int Axis6)> (CancellationToken = default)
{
// 从 47232 开始连续读取 22 个寄存器(覆盖 47232-47253
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, ADDR_ABS_AXIS1_POS, 22, ct);
ushort[] registers = await ReadHoldingRegistersAsync(_slaveAddress, ADDR_ABS_AXIS1_POS, 22, );
if (registers == null || registers.Length < 22)
throw new InvalidOperationException("批量读取 6 轴绝对位置失败,返回数据长度不足。");
@@ -536,5 +675,63 @@ namespace DeviceCommand.Devices
}
#endregion
#region
/// <summary>
/// 轮询等待三轴绝对位置均回到机械零点(容差 ±10),超时则抛出异常
/// </summary>
/// <param name="超时秒数">最大等待时间(秒)</param>
/// <param name="取消令牌">取消令牌</param>
[Browsable(false)]
public async Task (int , CancellationToken = default)
{
var deadline = DateTime.Now.AddSeconds();
while (DateTime.Now < deadline)
{
.ThrowIfCancellationRequested();
var pos = await ();
if (Math.Abs(pos.Axis1) <= 10
&& Math.Abs(pos.Axis6) <= 10
&& Math.Abs(pos.Axis2) <= 10)
return;
await Task.Delay(100, );
}
throw new TimeoutException($"等待 {超时秒数} 秒后,三轴仍未到达机械零点");
}
/// <summary>
/// 轮询等待三轴绝对位置均到达各自设定的目标位置(容差 ±10),超时则抛出异常
/// </summary>
/// <param name="超时秒数">最大等待时间(秒)</param>
/// <param name="取消令牌">取消令牌</param>
public async Task<bool> (int , CancellationToken = default)
{
var targetX = await (1, );
var targetY = await (2, );
var targetZ = await (3, );
var deadline = DateTime.Now.AddSeconds();
while (DateTime.Now < deadline)
{
.ThrowIfCancellationRequested();
var pos = await ();
if (Math.Abs(pos.Axis1 - targetX) <= 10
&& Math.Abs(pos.Axis6 - targetY) <= 10
&& Math.Abs(pos.Axis2 - targetZ) <= 10)
return true;
await Task.Delay(100, );
}
throw new TimeoutException($"等待 {超时秒数} 秒后,三轴仍未到达指定位置 (X:{targetX}, Y:{targetY}, Z:{targetZ})");
}
#endregion
}
}
+2 -1
View File
@@ -23,7 +23,8 @@ namespace DeviceCommand.Devices
public async Task (byte , ushort , bool data)
{
await Modbus.WriteSingleCoilAsync(, , data);
bool[] mydata = { data };
await (, , mydata);
}
public async Task (byte , ushort , bool[] datas)
{
+21 -11
View File
@@ -52,37 +52,37 @@ namespace DeviceCommand.Devices
{ 1, new Dictionary<, IoPortInfo> {
{ ., new IoPortInfo(1, 5) }, // Y6 -> 5
{ ., new IoPortInfo(1, 0) }, // Y1 -> 0
{ ., new IoPortInfo(1, 1) }, // Y2 -> 1
{ ., new IoPortInfo(1, 4) }, // Y2 -> 1
{ ., new IoPortInfo(2, 15) } // Y9 -> 8
}},
{ 2, new Dictionary<, IoPortInfo> {
{ ., new IoPortInfo(1, 6) }, // Y7 -> 6
{ ., new IoPortInfo(1, 2) }, // Y3 -> 2
{ ., new IoPortInfo(1, 1) }, // Y2 -> 1
{ ., new IoPortInfo(1, 1) }, // Y3 -> 2
{ ., new IoPortInfo(1, 4) }, // Y2 -> 1
{ ., new IoPortInfo(2, 14) } // Y10 -> 9
}},
{ 3, new Dictionary<, IoPortInfo> {
{ ., new IoPortInfo(1, 7) }, // Y8 -> 7
{ ., new IoPortInfo(1, 3) }, // Y4 -> 3
{ ., new IoPortInfo(1, 1) }, // Y2 -> 1
{ ., new IoPortInfo(1, 2) }, // Y4 -> 3
{ ., new IoPortInfo(1, 4) }, // Y2 -> 1
{ ., new IoPortInfo(2, 13) } // Y11 -> 10
}},
{ 4, new Dictionary<, IoPortInfo> {
{ ., new IoPortInfo(1, 8) }, // Y9 -> 8
{ ., new IoPortInfo(1, 4) }, // Y5 -> 4
{ ., new IoPortInfo(1, 1) }, // Y2 -> 1
{ ., new IoPortInfo(1, 3) }, // Y5 -> 4
{ ., new IoPortInfo(1, 4) }, // Y2 -> 1
{ ., new IoPortInfo(2, 12) } // Y12 -> 11
}},
{ 5, new Dictionary<, IoPortInfo> {
{ ., new IoPortInfo(2, 4) }, // Y5 -> 4
{ ., new IoPortInfo(2, 0) }, // Y1 -> 0
{ ., new IoPortInfo(1, 1) }, // Y2 -> 1
{ ., new IoPortInfo(1, 4) }, // Y2 -> 1
{ ., new IoPortInfo(2, 11) } // Y13 -> 12
}},
{ 6, new Dictionary<, IoPortInfo> {
{ ., new IoPortInfo(2, 5) }, // Y6 -> 5
{ ., new IoPortInfo(2, 1) }, // Y2 -> 1
{ ., new IoPortInfo(1, 1) }, // Y2 -> 1
{ ., new IoPortInfo(1,4) }, // Y2 -> 1
{ ., new IoPortInfo(2, 10) } // Y14 -> 13
}},
{ 7, new Dictionary<, IoPortInfo> {
@@ -94,7 +94,7 @@ namespace DeviceCommand.Devices
{ 8, new Dictionary<, IoPortInfo> {
{ ., new IoPortInfo(2, 7) }, // Y8 -> 7
{ ., new IoPortInfo(2, 3) }, // Y4 -> 3
{ ., new IoPortInfo(1, 1) }, // Y2 -> 1
{ ., new IoPortInfo(1, 4) }, // Y2 -> 1
{ ., new IoPortInfo(2, 8) } // Y16 -> 15
}}
};
@@ -141,7 +141,17 @@ namespace DeviceCommand.Devices
IOBoard = GetBoardInstance(io信息.ModuleIndex);
await .(_slaveAddress, io信息.Address, );
}
/// <summary>
/// 执行指定台架的功能动作控制(带高低电平开关及底层安全互锁)
/// </summary>
/// <param name="板卡序号">板卡序号 (0 - 1)</param>
/// <param name="开始地址">寄存器初始地址(0-15)</param>
/// <param name="数据">开关状态数组 (true: 开启吸合, false: 关闭断开)[true,true,false]</param>
/// <param name="取消令牌">异步取消令牌</param>
public async Task (int ,byte , ushort , bool[] , CancellationToken = default)
{
await GetBoardInstance().(, , );
}
/// <summary>
/// 内部路由辅助方法:根据板卡索引安全获取当前连接的实例对象
/// </summary>
+195 -13
View File
@@ -38,6 +38,21 @@ namespace DeviceCommand.Devices
CURRent
}
/// <summary>
/// 设备相位/输出模式[cite: 2]
/// </summary>
public enum DevicePhaseMode
{
/// <summary> 单相模式 </summary>
ONE,
/// <summary> 三相模式 </summary>
THRee,
/// <summary> 反相模式 </summary>
DIFFerence,
/// <summary> 多通道模式 </summary>
MULTichannel
}
#endregion
[ADPCommand]
@@ -52,21 +67,39 @@ namespace DeviceCommand.Devices
#region 1. IEEE 488.2
/// <summary>
/// 清除错误队列和状态字节
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"*CLS{ScpiDelimiter}", ct); //[cite: 1]
}
/// <summary>
/// 查询设备标识
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>设备标识字符串(制造商, 产品型号, 序列号, 固件版本)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"*IDN?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
}
/// <summary>
/// 重置设备
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"*RST{ScpiDelimiter}", ct); //[cite: 1]
}
/// <summary>
/// 读取状态字节
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>状态字节寄存器值</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"*STB?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
@@ -79,6 +112,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置电源输出源的电气模式/工作耦合模式 (AC, DC, ACDC, DCAC)[cite: 1]
/// </summary>
/// <param name="模式">电气耦合模式 (AC/DC/ACDC/DCAC)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (PowerCouplingMode , CancellationToken ct = default)
{
await SendAsync($":SOURce:FUNCtion {模式}{ScpiDelimiter}", ct); //[cite: 1]
@@ -87,17 +122,29 @@ namespace DeviceCommand.Devices
/// <summary>
/// 查询电源当前的工作电气模式[cite: 1]
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>当前工作电气模式字符串</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($":SOURce:FUNCtion?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
}
/// <summary>
/// 设置DC输出开关
/// </summary>
/// <param name="开启">true: 开启输出, false: 关闭输出</param>
/// <param name="ct">取消令牌</param>
public virtual async Task DC输出(bool , CancellationToken ct = default)
{
string = ? "ON" : "OFF"; //[cite: 1]
await SendAsync($":OUTPut {参数}{ScpiDelimiter}", ct); //[cite: 1]
}
/// <summary>
/// 查询DC输出状态
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>输出状态字符串(ON 或 OFF</returns>
public virtual async Task<string> DC输出状态(CancellationToken ct = default)
{
return await WriteReadAsync($":OUTPut?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
@@ -107,24 +154,44 @@ namespace DeviceCommand.Devices
#region 3. (AC //DC )
/// <summary>
/// 设置交流电压
/// </summary>
/// <param name="电压">交流电压设定值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, ":SOURce:VOLTage {0:F2}{1}", , ScpiDelimiter); //[cite: 1]
await SendAsync(cmd, ct);
}
/// <summary>
/// 设置直流电压
/// </summary>
/// <param name="电压">直流电压设定值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, ":SOURce:VOLTage:DC {0:F2}{1}", , ScpiDelimiter); //[cite: 1]
await SendAsync(cmd, ct);
}
/// <summary>
/// 设置频率
/// </summary>
/// <param name="频率">频率设定值 (单位: Hz)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, ":SOURce:FREQuency {0:F2}{1}", , ScpiDelimiter); //[cite: 1]
await SendAsync(cmd, ct);
}
/// <summary>
/// 设置电流
/// </summary>
/// <param name="电流">电流设定值 (单位: A)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, ":SOURce:CURRent {0:F3}{1}", , ScpiDelimiter); //[cite: 1]
@@ -135,70 +202,132 @@ namespace DeviceCommand.Devices
#region 4.
/// <summary>
/// 查询实际电压
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实际电压测量值 (单位: V)</returns>
[Monitorable("交流电源电压")]
public virtual async Task<string> (CancellationToken ct = default)
public virtual async Task<double> (CancellationToken ct = default)
{
return await WriteReadAsync($":MEASure:VOLTage?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
string response = await WriteReadAsync($":MEASure:VOLTage?{ScpiDelimiter}", ScpiDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
/// <summary>
/// 查询实际电流
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实际电流测量值 (单位: A)</returns>
[Monitorable("交流电源电流")]
public virtual async Task<string> (CancellationToken ct = default)
public virtual async Task<double> (CancellationToken ct = default)
{
return await WriteReadAsync($":MEASure:CURRent?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
string response = await WriteReadAsync($":MEASure:CURRent?{ScpiDelimiter}", ScpiDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
/// <summary>
/// 查询实际功率
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实际有功功率测量值 (单位: W)</returns>
[Monitorable("交流电源功率")]
public virtual async Task<string> (CancellationToken ct = default)
public virtual async Task<double> (CancellationToken ct = default)
{
return await WriteReadAsync($":MEASure:POWer?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
string response = await WriteReadAsync($":MEASure:POWer?{ScpiDelimiter}", ScpiDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
public virtual async Task<string> (CancellationToken ct = default)
/// <summary>
/// 查询视在功率
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>视在功率测量值 (单位: VA)</returns>
public virtual async Task<double> (CancellationToken ct = default)
{
return await WriteReadAsync($":MEASure:POWer:APParent?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
string response = await WriteReadAsync($":MEASure:POWer:APParent?{ScpiDelimiter}", ScpiDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
public virtual async Task<string> (CancellationToken ct = default)
/// <summary>
/// 查询实际频率
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实际频率测量值 (单位: Hz)</returns>
public virtual async Task<double> (CancellationToken ct = default)
{
return await WriteReadAsync($":MEASure:FREQuency?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
string response = await WriteReadAsync($":MEASure:FREQuency?{ScpiDelimiter}", ScpiDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
public virtual async Task<string> (CancellationToken ct = default)
/// <summary>
/// 查询功率因数
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>功率因数测量值</returns>
public virtual async Task<double> (CancellationToken ct = default)
{
return await WriteReadAsync($":MEASure:POWer:PFACtor?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
string response = await WriteReadAsync($":MEASure:POWer:PFACtor?{ScpiDelimiter}", ScpiDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
#endregion
#region 5.
/// <summary>
/// 设置过流保护_OCP
/// </summary>
/// <param name="电流">过流保护阈值 (单位: A, RMS值)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task _OCP(double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, ":SOURce:CURRent:PROTection:RMS {0:F3}{1}", , ScpiDelimiter); //[cite: 1]
await SendAsync(cmd, ct);
}
/// <summary>
/// 设置过压保护_OVP
/// </summary>
/// <param name="电压">过压保护阈值 (单位: V, 峰值)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task _OVP(double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, ":SOURce:VOLTage:PROTection:PEAK {0:F2}{1}", , ScpiDelimiter); //[cite: 1]
await SendAsync(cmd, ct);
}
/// <summary>
/// 查询错误信息
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>错误代码和描述信息</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($":SYSTem:ERRor?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
}
/// <summary>
/// 切换远程控制模式
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($":SYSTem:REMote{ScpiDelimiter}", ct); //[cite: 1]
}
/// <summary>
/// 切换本地控制模式
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($":SYSTem:LOCal{ScpiDelimiter}", ct); //[cite: 1]
}
/// <summary>
/// 清除保护告警
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($":OUTPut:PROTection:CLEar{ScpiDelimiter}", ct); //[cite: 1]
@@ -211,6 +340,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 清除最近的安时(Ah)与瓦时(Wh)统计数据[cite: 1]
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"SENSe:AHOur:RESet{ScpiDelimiter}", ct); //[cite: 1]
@@ -220,6 +350,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 读取当前充放电累计的安时值 (单位: Ah)[cite: 1]
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>累计安时值 (单位: Ah)</returns>
public virtual async Task<string> _Ah(CancellationToken ct = default)
{
return await WriteReadAsync($"FETCh:AHOur?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
@@ -228,6 +360,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 读取当前充放电累计的瓦时值 (单位: Wh)[cite: 1]
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>累计瓦时值 (单位: Wh)</returns>
public virtual async Task<string> _Wh(CancellationToken ct = default)
{
return await WriteReadAsync($"FETCh:WHOur?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
@@ -236,6 +370,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 读取充放电积分持续时间 (单位: 秒)[cite: 1]
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>积分持续时间 (单位: 秒)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"FETCh:ENERgy:TIME?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
@@ -244,6 +380,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 切换设备的操作模式 (VOLTage: 电源模式, LOAD: 负载模式/放电, CURRent: 电流源)[cite: 1]
/// </summary>
/// <param name="模式">操作模式 (VOLTage/LOAD/CURRent)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (DeviceOperationMode , CancellationToken ct = default)
{
await SendAsync($"SYSTem:OPERation:MODE {模式}{ScpiDelimiter}", ct); //[cite: 1]
@@ -252,6 +390,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置放电截止电压/电压下限 (单位: V,用于防止电池过放)[cite: 1]
/// </summary>
/// <param name="电压">放电截止电压/电压下限 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, ":SOURce:VOLTage:LIMit:LOW {0:F2}{1}", , ScpiDelimiter); //[cite: 1]
@@ -259,5 +399,47 @@ namespace DeviceCommand.Devices
}
#endregion
#region 7. (//)[cite: 2]
/// <summary>
/// 设置电源相位输出模式 (ONE:单相 / THRee:三相 / DIFFerence:反相 / MULTichannel:多通道)[cite: 2]
/// </summary>
/// <param name="模式">相位输出模式</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (DevicePhaseMode , CancellationToken ct = default)
{
await SendAsync($"SYSTem:FUNCtion {模式}{ScpiDelimiter}", ct); //[cite: 2]
}
/// <summary>
/// 切换为单相模式 (SYST:FUNC ONE)[cite: 2]
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"SYSTem:FUNCtion ONE{ScpiDelimiter}", ct); //[cite: 2]
}
/// <summary>
/// 切换为反相模式 (SYST:FUNC DIFFerence)[cite: 2]
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"SYSTem:FUNCtion DIFFerence{ScpiDelimiter}", ct); //[cite: 2]
}
/// <summary>
/// 查询当前输出模式 (单相/三相/反相/多通道)[cite: 2]
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>返回 ONE, THRee, DIFFerence 或 MULTichannel[cite: 2]</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"SYSTem:FUNCtion?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 2]
}
#endregion
}
}
+104 -6
View File
@@ -3,6 +3,7 @@ using DeviceCommand.Base;
using Model.Models;
using System;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -22,11 +23,31 @@ namespace DeviceCommand.Devices
{
}
/// <summary>
/// 从设备返回的 SCPI 响应字符串中提取数值部分并转换为 double。
/// 先按逗号分割取数据段,再提取数值。
/// </summary>
private static double ExtractDouble(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return 0;
string dataPart = raw;
int commaIdx = raw.LastIndexOf(',');
if (commaIdx >= 0 && commaIdx < raw.Length - 1)
dataPart = raw.Substring(commaIdx + 1);
var match = Regex.Match(dataPart, @"[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?");
return match.Success && double.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double val)
? val
: 0;
}
#region 3.1. IEEE 488.2
/// <summary>
/// 3.1.1. 清除标准事件状态寄存器和错误队列
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"*CLS{ScpiDelimiter}", ct);
@@ -35,6 +56,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.1.4. 读取直流电源相关信息(制造商、产品型号、系统 SN、软件版本号)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>设备标识字符串(制造商, 产品型号, 序列号, 固件版本)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"*IDN?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -43,6 +66,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.1.7. 恢复出厂设置 (注意:设备重置保存数据大约需要 10 秒)
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"*RST{ScpiDelimiter}", ct);
@@ -51,6 +75,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.1.8. 读取状态字节寄存器(只读寄存器,读取时不会清除位)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>状态字节寄存器值</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"*STB?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -63,6 +89,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.2.1. 设定输出电压值 (单位: V)
/// </summary>
/// <param name="电压">输出电压设定值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "SOURce:VOLTage {0:F3}{1}", , ScpiDelimiter);
@@ -72,6 +100,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.2.1. 查询输出电压设定值 (单位: V)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>输出电压设定值 (单位: V)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"SOURce:VOLTage?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -80,6 +110,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.2.2. 设置输出限流值 (单位: A)
/// </summary>
/// <param name="电流">输出限流设定值 (单位: A)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "SOURce:CURRent {0:F3}{1}", , ScpiDelimiter);
@@ -89,6 +121,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.2.2. 查询输出限流值设定值 (单位: A)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>输出限流设定值 (单位: A)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"SOURce:CURRent?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -97,6 +131,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.2.3. 设置输出模拟内阻值 (单位: mΩ)
/// </summary>
/// <param name="内阻">模拟内阻设定值 (单位: mΩ)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "SOURce:INTErnalres {0:F1}{1}", , ScpiDelimiter);
@@ -106,6 +142,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.2.4. 保存当前测试参数到指定存储组 (范围: 1-10)
/// </summary>
/// <param name="组别">存储组编号 (范围: 1-10)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int , CancellationToken ct = default)
{
if ( < 1 || > 10) throw new ArgumentOutOfRangeException(nameof(), "组别有效范围为 1~10");
@@ -115,6 +153,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.2.5. 调用指定存储组的测试参数 (范围: 1-10)
/// </summary>
/// <param name="组别">存储组编号 (范围: 1-10)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int , CancellationToken ct = default)
{
if ( < 1 || > 10) throw new ArgumentOutOfRangeException(nameof(), "组别有效范围为 1~10");
@@ -128,6 +168,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.1. 控制电源输出开关 (True: 开启, False: 关闭)
/// </summary>
/// <param name="开启">true: 开启输出, false: 关闭输出</param>
/// <param name="ct">取消令牌</param>
public virtual async Task DC输出(bool , CancellationToken ct = default)
{
string = ? "ON" : "OFF";
@@ -137,6 +179,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.1. 查询电源输出开关状态 (返回 "ON" 或 "OFF")
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>输出开关状态字符串("ON" 或 "OFF"</returns>
public virtual async Task<string> DC输出开关状态(CancellationToken ct = default)
{
return await WriteReadAsync($"OUTPut:ONOFF?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -146,6 +190,8 @@ namespace DeviceCommand.Devices
/// 3.3.2. 设定电源工作模式
/// (NORMal: 普通模式, CHARge: 电池充电, SEQuence: 序列模式, CPOWer: 恒功率模式, CARWave: 汽车测试, APG: 外部编程)
/// </summary>
/// <param name="模式">工作模式 (NORMal/CHARge/SEQuence/CPOWer/CARWave/APG)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (string , CancellationToken ct = default)
{
await SendAsync($"OUTPut:MODE {模式}{ScpiDelimiter}", ct);
@@ -154,6 +200,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.2. 查询电源当前运行工作模式
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>当前运行工作模式字符串</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"OUTPut:MODE?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -162,6 +210,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.11. 设置电源输出 CV/CC 优先权 (CV 或 CC)
/// </summary>
/// <param name="优先模式">优先模式,只能为 "CV" 或 "CC"</param>
/// <param name="ct">取消令牌</param>
public virtual async Task CVCC优先权(string , CancellationToken ct = default)
{
if ( != "CV" && != "CC") throw new ArgumentException("优先模式只能为 'CV' 或 'CC'");
@@ -171,6 +221,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.12. 获取电源状态字(通过解析返回整数的二进制 Bit 位获取全状态环路及告警)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>电源状态字(整数字符串,通过二进制位标识各状态及告警)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"OUTPut:STATe?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -179,6 +231,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.13. 获取电源事件告警状态值 (返回整数通过位定义标识 UVP/OVP/OCP/OPP/OTP)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>事件告警状态值(整数字符串,通过位定义标识 UVP/OVP/OCP/OPP/OTP</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"OUTPut:EVENT?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -187,6 +241,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.13. 清除当前的告警状态
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"OUTPut:EVENT 0{ScpiDelimiter}", ct);
@@ -195,6 +250,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.16. 打开/关闭设备定时关机功能
/// </summary>
/// <param name="开启">true: 开启定时关机, false: 关闭定时关机</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (bool , CancellationToken ct = default)
{
string = ? "ON" : "OFF";
@@ -204,6 +261,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.17. 设定设备定时关机倒计时时间 (单位: s)
/// </summary>
/// <param name="秒数">定时关机倒计时时间 (单位: s)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "OUTPut:TIMing:DWELI {0:F1}{1}", , ScpiDelimiter);
@@ -213,6 +272,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.3.18. 控制泄放电路(Bleeder)开关状态
/// </summary>
/// <param name="开启">true: 开启泄放电路, false: 关闭泄放电路</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (bool , CancellationToken ct = default)
{
string = ? "ON" : "OFF";
@@ -226,33 +287,44 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.4.1. 回读通道输出端子上的实时测得电压值 (单位: V)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实时测量电压值 (单位: V)</returns>
[Monitorable("高压直流电源电压")]
public virtual async Task<string> (CancellationToken ct = default)
public virtual async Task<double> (CancellationToken ct = default)
{
return await WriteReadAsync($"MEASure:VOLTage?{ScpiDelimiter}", ScpiDelimiter, ct);
string res = await WriteReadAsync($"MEASure:VOLTage?{ScpiDelimiter}", ScpiDelimiter, ct);
return ExtractDouble(res);
}
/// <summary>
/// 3.4.2. 回读通道输出端子上的实时测得电流值 (单位: A)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实时测量电流值 (单位: A)</returns>
[Monitorable("高压直流电源电流")]
public virtual async Task<string> (CancellationToken ct = default)
public virtual async Task<double> (CancellationToken ct = default)
{
return await WriteReadAsync($"MEASure:CURRent?{ScpiDelimiter}", ScpiDelimiter, ct);
string res = await WriteReadAsync($"MEASure:CURRent?{ScpiDelimiter}", ScpiDelimiter, ct);
return ExtractDouble(res);
}
/// <summary>
/// 3.4.3. 回读通道输出端子上的实时测得功率值 (单位: W)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实时测量功率值 (单位: W)</returns>
[Monitorable("高压直流电源功率")]
public virtual async Task<string> (CancellationToken ct = default)
public virtual async Task<double> (CancellationToken ct = default)
{
return await WriteReadAsync($"MEASure:POWer?{ScpiDelimiter}", ScpiDelimiter, ct);
string res = await WriteReadAsync($"MEASure:POWer?{ScpiDelimiter}", ScpiDelimiter, ct);
return ExtractDouble(res);
}
/// <summary>
/// 3.4.4. 回读电池充电模式下当前累计已充入的容量值 (单位: mAh)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>累计已充入容量值 (单位: mAh)</returns>
public virtual async Task<string> MAH(CancellationToken ct = default)
{
return await WriteReadAsync($"MEASure:MAH?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -261,6 +333,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.4.5. 获取当前电源的硬件额定电压上限值 (单位: V)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>硬件额定电压上限值 (单位: V)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"MEASure:VOLTage:MAXimum?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -269,6 +343,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.4.6. 获取当前电源的硬件额定电流上限值 (单位: A)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>硬件额定电流上限值 (单位: A)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"MEASure:CURRent:MAXimum?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -281,6 +357,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.5.1. 设置欠压保护门限值 (单位: V)
/// </summary>
/// <param name="电压">欠压保护门限值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task _UVP(double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "PROTect:LESS:VOLTage {0:F3}{1}", , ScpiDelimiter);
@@ -290,6 +368,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.5.4. 设置过压保护门限值 (单位: V)
/// </summary>
/// <param name="电压">过压保护门限值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task _OVP(double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "PROTect:OVER:VOLTage {0:F3}{1}", , ScpiDelimiter);
@@ -299,6 +379,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.5.6. 设置硬件硬件过流保护硬指标参数 (单位: A)
/// </summary>
/// <param name="电流">过流保护硬指标参数 (单位: A)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task _OCP(double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "PROTect:CURRent {0:F3}{1}", , ScpiDelimiter);
@@ -308,6 +390,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.5.8. 设置硬件过功率保护门限值 (单位: W)
/// </summary>
/// <param name="功率">过功率保护门限值 (单位: W)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task _OPP(double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "PROTect:POWer {0:F3}{1}", , ScpiDelimiter);
@@ -317,6 +401,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.5.12. 设置可调节的用户软输出电压下限值 (单位: V)
/// </summary>
/// <param name="电压">输出电压下限值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "SOURce:VOLTage:LEVel:LIMit:LOW {0:F3}{1}", , ScpiDelimiter);
@@ -326,6 +412,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.5.13. 设置可调节的用户软输出电压上限值 (单位: V)
/// </summary>
/// <param name="电压">输出电压上限值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "SOURce:VOLTage:LEVel:LIMit {0:F3}{1}", , ScpiDelimiter);
@@ -335,6 +423,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.5.14. 设置可调节的用户软输出电流下限值 (单位: A)
/// </summary>
/// <param name="电流">输出电流下限值 (单位: A)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "SOURce:CURRent:LEVel:LIMit:LOW {0:F3}{1}", , ScpiDelimiter);
@@ -344,6 +434,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.5.15. 设置可调节的用户软输出电流上限值 (单位: A)
/// </summary>
/// <param name="电流">输出电流上限值 (单位: A)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "SOURce:CURRent:LEVel:LIMit {0:F3}{1}", , ScpiDelimiter);
@@ -357,6 +449,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.6.1. 设定恒功率工作模式下的限定电压值 (单位: V)
/// </summary>
/// <param name="电压">恒功率模式限定电压值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "CPOWer:VOLTage {0:F3}{1}", , ScpiDelimiter);
@@ -366,6 +460,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.6.2. 设定恒功率工作模式下的限定电流值 (单位: A)
/// </summary>
/// <param name="电流">恒功率模式限定电流值 (单位: A)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "CPOWer:CURRent {0:F3}{1}", , ScpiDelimiter);
@@ -375,6 +471,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.6.3. 设定恒功率工作模式下的运行功率目标值 (单位: W)
/// </summary>
/// <param name="功率">恒功率模式运行功率目标值 (单位: W)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "CPOWer:POWer {0:F3}{1}", , ScpiDelimiter);
+82 -14
View File
@@ -30,6 +30,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 建立 TCP 连接,成功后自动激活心跳
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>连接是否成功</returns>
public new async Task<bool> ConnectAsync(CancellationToken ct = default)
{
bool isConnected = await base.ConnectAsync(ct);
@@ -124,6 +126,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.1.1 读取电源的相关信息(制造商, 产品标号, 产品序列号, 软件版本号)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>设备标识字符串(制造商, 产品标号, 产品序列号, 软件版本号)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"*IDN?{SCPIDelimiter}", SCPIDelimiter, ct);
@@ -132,6 +136,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.1.2 从指定的单位中恢复 *SAV 命令保存的设定值 (参数:1~99)
/// </summary>
/// <param name="组别">存储组编号 (范围: 1~99)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int , CancellationToken ct = default)
{
if ( < 1 || > 99) throw new ArgumentOutOfRangeException(nameof(), "组别有效范围为 1~99");
@@ -141,6 +147,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.1.3 将仪器当前系统状态保存到非易失性内存中 (参数:1~99)
/// </summary>
/// <param name="组别">存储组编号 (范围: 1~99)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int , CancellationToken ct = default)
{
if ( < 1 || > 99) throw new ArgumentOutOfRangeException(nameof(), "组别有效范围为 1~99");
@@ -150,6 +158,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.1.4 远程控制触发一次
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"*TRG{SCPIDelimiter}", ct);
@@ -158,6 +167,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.1.5 返回状态、采集电压、采集电流 (格式: 状态码,电压值,电流值)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>状态码,电压值,电流值的组合字符串</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"*ALL?{SCPIDelimiter}", SCPIDelimiter, ct);
@@ -170,6 +181,9 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.2.1 设置输出电压和输出电流值
/// </summary>
/// <param name="电压">输出电压设定值 (单位: V)</param>
/// <param name="电流">输出电流设定值 (单位: A)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , double , CancellationToken ct = default)
{
// 修正:去除前缀冒号,规范应用 APPL 短格式
@@ -180,6 +194,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.2.1 查询当前设定的输出电压和电流值
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>当前输出电压和电流设定值的组合字符串</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -193,50 +209,59 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.5.1 查询通道输出端子上测得的直流电流值 (A)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实测直流电流值 (单位: A)</returns>
[Monitorable("低压直流电源电流")]
public virtual async Task<string> (CancellationToken ct = default)
public virtual async Task<double> (CancellationToken ct = default)
{
// 修正:去除前缀冒号,改用标准 MEAS:CURR?
return await WriteReadAsync($"MEAS:CURR?{SCPIDelimiter}", SCPIDelimiter, ct);
string response = await WriteReadAsync($"MEAS:CURR?{SCPIDelimiter}", SCPIDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
/// <summary>
/// 3.5.2 查询通道输出端子上测得的直流功率值 (W)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实测直流功率值 (单位: W)</returns>
[Monitorable("低压直流电源功率")]
public virtual async Task<string> (CancellationToken ct = default)
public virtual async Task<double> (CancellationToken ct = default)
{
// 修正:去除前缀冒号,改用标准 MEAS:POW?
return await WriteReadAsync($"MEAS:POW?{SCPIDelimiter}", SCPIDelimiter, ct);
string response = await WriteReadAsync($"MEAS:POW?{SCPIDelimiter}", SCPIDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
/// <summary>
/// 3.5.3 查询通道输出端子上测得的直流电压值 (V)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>实测直流电压值 (单位: V)</returns>
[Monitorable("低压直流电源电压")]
public virtual async Task<string> (CancellationToken ct = default)
public virtual async Task<double> (CancellationToken ct = default)
{
// 修正:去除前缀冒号,改用标准 MEAS:VOLT?
return await WriteReadAsync($"MEAS:VOLT?{SCPIDelimiter}", SCPIDelimiter, ct);
string response = await WriteReadAsync($"MEAS:VOLT?{SCPIDelimiter}", SCPIDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
/// <summary>
/// 3.5.4 查询通道输出端子上测得的电压、电流和功率的组合值
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>电压、电流和功率的组合值字符串</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
// 修正:去除前缀冒号
return await WriteReadAsync($"MEAS:VAP?{SCPIDelimiter}", SCPIDelimiter, ct);
}
/// <summary>
/// 3.5.5 查询通道输出端子上测得的输出时间长度
/// </summary>
public virtual async Task<string> (CancellationToken ct = default)
/// <param name="ct">取消令牌</param>
/// <returns>输出时间长度</returns>
public virtual async Task<double> (CancellationToken ct = default)
{
// 修正:去除前缀冒号
return await WriteReadAsync($"MEAS:TIME:OUTP?{SCPIDelimiter}", SCPIDelimiter, ct);
string response = await WriteReadAsync($"MEAS:TIME:OUTP?{SCPIDelimiter}", SCPIDelimiter, ct);
return double.TryParse(response, out double result) ? result : double.NaN;
}
#endregion
@@ -246,6 +271,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.6.1 设置电源输出开关
/// </summary>
/// <param name="开启">true: 开启输出, false: 关闭输出</param>
/// <param name="ct">取消令牌</param>
public virtual async Task DC输出(bool , CancellationToken ct = default)
{
// 修正:去除前缀冒号,改用 OUTP 短格式
@@ -256,6 +283,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.6.1 查询通道输出状态 (返回 ON 或 OFF)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>通道输出状态字符串("ON" 或 "OFF"</returns>
public virtual async Task<string> DC输出状态(CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -265,6 +294,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.6.2 控制电源输出定时器的开关状态
/// </summary>
/// <param name="开启">true: 开启定时器, false: 关闭定时器</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (bool , CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -275,6 +306,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.6.3 设定电源输出定时器的时间 (单位: s, 范围: 0.1 ~ 999999.9)
/// </summary>
/// <param name="秒数">定时器时间 (单位: s, 范围: 0.1 ~ 999999.9)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -285,6 +318,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.6.4 设定电源输出的模式优先权 (CV优先 或 CC优先)
/// </summary>
/// <param name="是CV优先">true: CV优先, false: CC优先</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (bool CV优先, CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -299,6 +334,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.1.1 清除输出电流保护(OCP)电路状态
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -308,6 +344,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.1.2 使能或禁止当前输出电流保护(OCP)电路
/// </summary>
/// <param name="启用">true: 使能OCP, false: 禁止OCP</param>
/// <param name="ct">取消令牌</param>
public virtual async Task 使(bool , CancellationToken ct = default)
{
// 修正:去除前缀冒号,将 STATE 改为更常用的 STAT 简写
@@ -318,6 +356,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.1.4 设置输出电流保护(OCP)阀值 (A)
/// </summary>
/// <param name="电流">OCP 保护阀值 (单位: A)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
// 修正:去除前缀冒号。按 NGI 通用指令树,加上层级限制或者直接作用于 CURR:PROT
@@ -328,6 +368,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.1.7 设置电源的输出电流值 (A)
/// </summary>
/// <param name="电流">输出电流设定值 (单位: A)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
// 修正:去除前缀冒号,改用标准 CURR 短格式
@@ -338,6 +380,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.2.1 清除输出电压保护(OVP)电路状态
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -347,6 +390,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.2.2 使能或禁止当前输出电压保护(OVP)电路
/// </summary>
/// <param name="启用">true: 使能OVP, false: 禁止OVP</param>
/// <param name="ct">取消令牌</param>
public virtual async Task 使(bool , CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -357,6 +402,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.2.4 设置输出电压保护(OVP)阀值 (V)
/// </summary>
/// <param name="电压">OVP 保护阀值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -367,6 +414,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.2.7 设置电源的输出电压值 (V)
/// </summary>
/// <param name="电压">输出电压设定值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
// 修正:去除前缀冒号,改用标准 VOLT 短格式
@@ -377,23 +426,30 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.2.8 设定电压输出范围的上限电压值 (V)
/// </summary>
/// <param name="电压">上限电压值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
// 修正:去除前缀冒号
string cmd = string.Format(CultureInfo.InvariantCulture, "VOLT:LIM {0:F3}{1}", , SCPIDelimiter);
await SendAsync(cmd, ct);
}
/// <summary>
/// 3.7.2.9 清除电压上限限制(恢复为设备物理允许的最大电压值)
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
// 发送 MAXimum 设为最大值以达到清除限制的效果
// 发送 MAXimum 设为最大值以达到"清除限制"的效果
await SendAsync($"VOLT:LIMIT MAX{SCPIDelimiter}", ct); // 对应手册 3.7.2.8
}
/// <summary>
/// 3.7.3.1 设置输出功率值 (W)
/// </summary>
/// <param name="功率">输出功率设定值 (单位: W)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double , CancellationToken ct = default)
{
// 修正:去除前缀冒号,改用标准 POW 短格式
@@ -404,6 +460,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.7.3.2 禁止或使能当前恒功率(CP)输出
/// </summary>
/// <param name="启用">true: 使能恒功率输出, false: 禁止恒功率输出</param>
/// <param name="ct">取消令牌</param>
public virtual async Task 使(bool , CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -418,6 +476,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.9.1 控制蜂鸣器开关
/// </summary>
/// <param name="开启">true: 开启蜂鸣器, false: 关闭蜂鸣器</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (bool , CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -428,6 +488,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.9.2 使蜂鸣器强制鸣叫一声
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -437,6 +498,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.9.4 查询仪器当前出错记录数量 (最大 18 组)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>当前出错记录数量(最大 18 组)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -446,6 +509,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.9.5 读取仪器的出错信息 (成功返回 0,"No error")
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>错误代码和描述信息(成功返回 0,"No error"</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -455,6 +520,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.9.7 设置电源为面板控制模式 (本地 Local 状态,前面板按键可用)
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -464,6 +530,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.9.8 设置电源为远程控制模式 (Remote 状态)
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
// 修正:去除前缀冒号
@@ -473,6 +540,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 3.9.9 通过通信接口设置电源为远程控制锁定模式
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
// 修正:去除前缀冒号
File diff suppressed because it is too large Load Diff
+94 -7
View File
@@ -4,6 +4,7 @@ using Model.Models;
using System;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -22,11 +23,35 @@ namespace DeviceCommand.Devices
{
}
/// <summary>
/// 从示波器返回的 SCPI 响应字符串中提取数值部分并转换为 double。
/// 响应格式示例: "C1:PAVA PKPK,2.48E-03V" → 按逗号分割后提取 2.48E-03
/// </summary>
/// <param name="raw">示波器返回的原始字符串</param>
/// <returns>提取到的数值,解析失败时返回 0</returns>
private static double ExtractDouble(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return 0;
string dataPart = raw;
// 按逗号分割,取最后一段(数值通常在逗号后面)
int commaIdx = raw.LastIndexOf(',');
if (commaIdx >= 0 && commaIdx < raw.Length - 1)
dataPart = raw.Substring(commaIdx + 1);
// 从数据段中提取数值(含正负号、小数点、科学计数法)
var match = Regex.Match(dataPart, @"[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?");
return match.Success && double.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double val)
? val
: 0;
}
#region 1. IEEE 488.2
/// <summary>
/// 清除标准事件状态寄存器和错误队列
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"*CLS{ScpiDelimiter}", ct);
@@ -34,6 +59,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 查询错误信息
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>错误代码和描述信息</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($":SYSTem:ERRor?{ScpiDelimiter}", ScpiDelimiter, ct); //[cite: 1]
@@ -41,6 +68,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 读取示波器识别字符串(制造商、型号、序列号、固件版本)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>设备标识字符串(制造商, 型号, 序列号, 固件版本)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"*IDN?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -49,6 +78,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 复位命令。使示波器恢复到默认的出厂设置
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"*RST{ScpiDelimiter}", ct);
@@ -57,6 +87,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 查询先前操作是否完成。
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>true: 操作已完成, false: 操作仍在执行中</returns>
public virtual async Task<bool> _OPC(CancellationToken ct = default)
{
string res = await WriteReadAsync($"*OPC?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -70,6 +102,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 控制示波器开始捕获波形
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task _RUN(CancellationToken ct = default)
{
await SendAsync($":TRIGger:RUN{ScpiDelimiter}", ct);
@@ -78,6 +111,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 停止捕获波形
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task _STOP(CancellationToken ct = default)
{
await SendAsync($"TRIGger:STOP{ScpiDelimiter}", ct);
@@ -86,6 +120,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 强制示波器进入单次触发捕获模式
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task _SINGLE(CancellationToken ct = default)
{
await SendAsync($":TRIGger:MODE SINGle{ScpiDelimiter}", ct);
@@ -94,6 +129,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 触发一次波形采样
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"::TRIGger:MODE FTRIG{ScpiDelimiter}", ct);
@@ -136,6 +172,9 @@ namespace DeviceCommand.Devices
/// <summary>
/// 开启或关闭指定的模拟通道 (符合手册 57 页规范)
/// </summary>
/// <param name="channel">通道号 (1 - 4)</param>
/// <param name="enable">true: 开启通道, false: 关闭通道</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int channel, bool enable, CancellationToken ct = default)
{
string state = enable ? "ON" : "OFF";
@@ -145,6 +184,9 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置指定通道的垂直电压档位 (Volts/Div)
/// </summary>
/// <param name="channel">通道号 (1 - 4)</param>
/// <param name="volts">电压档位 (Volts/Div)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int channel, double volts, CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "C{0}:VDIV {1:F4}{2}", channel, volts, ScpiDelimiter);
@@ -154,14 +196,21 @@ namespace DeviceCommand.Devices
/// <summary>
/// 查询指定通道当前的电压档位
/// </summary>
public virtual async Task<string> (int channel, CancellationToken ct = default)
/// <param name="channel">通道号 (1 - 4)</param>
/// <param name="ct">取消令牌</param>
/// <returns>当前电压档位值 (Volts/Div)</returns>
public virtual async Task<double> (int channel, CancellationToken ct = default)
{
return await WriteReadAsync($"C{channel}:VDIV?{ScpiDelimiter}", ScpiDelimiter, ct);
string res = await WriteReadAsync($"C{channel}:VDIV?{ScpiDelimiter}", ScpiDelimiter, ct);
return ExtractDouble(res);
}
/// <summary>
/// 设置指定通道的垂直偏移量 (Offset)
/// </summary>
/// <param name="channel">通道号 (1 - 4)</param>
/// <param name="offset">垂直偏移量 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int channel, double offset, CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "C{0}:OFST {1:F4}{2}", channel, offset, ScpiDelimiter);
@@ -178,6 +227,13 @@ namespace DeviceCommand.Devices
/// <summary>1MΩ 阻抗</summary>
ONEM
}
/// <summary>
/// 设置指定通道的输入阻抗类型
/// </summary>
/// <param name="channel">通道号 (1 - 4)</param>
/// <param name="impedance">阻抗类型枚举 (FIFty: 50Ω, ONEM: 1MΩ)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int channel, ImpedanceType impedance, CancellationToken ct = default)
{
// 例如发送:C1:IMPedance FIFty\n 或 C1:IMPedance ONEM\n
@@ -192,6 +248,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置示波器的水平时基档位 (Time/Div)
/// </summary>
/// <param name="scale">水平时基档位 (Time/Div)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double scale, CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "TDIV {0:E6}{1}", scale, ScpiDelimiter);
@@ -201,6 +259,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置示波器的触发水平延迟位置 (Horizontal Delay)
/// </summary>
/// <param name="delay">水平延迟位置 (单位: s)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double delay, CancellationToken ct = default)
{
string cmd = string.Format(CultureInfo.InvariantCulture, "TRDL {0:E6}{1}", delay, ScpiDelimiter);
@@ -214,6 +274,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置边沿触发的电平值 (Trigger Level)
/// </summary>
/// <param name="level">触发电平值 (单位: V)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (double level, CancellationToken ct = default)
{
@@ -224,6 +286,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 设置边沿触发的触发源 (例如: C1, C2, C3, C4, EX, LINE)
/// </summary>
/// <param name="source">触发源通道标识 (例如: "C1", "C2", "EX", "LINE")</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (string source, CancellationToken ct = default)
{
await SendAsync($"TRSE EDGE,SR,{source.ToUpper()}{ScpiDelimiter}", ct);
@@ -236,16 +300,24 @@ namespace DeviceCommand.Devices
/// <summary>
/// 查询指定通道自动测量项的当前实时测量数值
/// </summary>
public virtual async Task<string> (int channel, string paramName, CancellationToken ct = default)
/// <param name="channel">通道号 (1 - 4)</param>
/// <param name="paramName">测量参数名称 (如 "PKPK", "FREQ", "RMS" 等)</param>
/// <param name="ct">取消令牌</param>
/// <returns>测量参数的实时数值 (double)</returns>
public virtual async Task<double> (int channel, string paramName, CancellationToken ct = default)
{
string query = string.Format(CultureInfo.InvariantCulture, "C{0}:PAVA? {1}{2}", channel, paramName.ToUpper(), ScpiDelimiter);
return await WriteReadAsync(query, ScpiDelimiter, ct);
string res = await WriteReadAsync(query, ScpiDelimiter, ct);
return ExtractDouble(res);
}
/// <summary>
/// 查询指定通道的电压峰峰值 (Vpp)
/// </summary>
public virtual async Task<string> (int channel, CancellationToken ct = default)
/// <param name="channel">通道号 (1 - 4)</param>
/// <param name="ct">取消令牌</param>
/// <returns>电压峰峰值 (Vpp)</returns>
public virtual async Task<double> (int channel, CancellationToken ct = default)
{
return await (channel, "PKPK", ct);
}
@@ -253,7 +325,10 @@ namespace DeviceCommand.Devices
/// <summary>
/// 查询指定通道的频率值 (Frequency)
/// </summary>
public virtual async Task<string> (int channel, CancellationToken ct = default)
/// <param name="channel">通道号 (1 - 4)</param>
/// <param name="ct">取消令牌</param>
/// <returns>频率值 (单位: Hz)</returns>
public virtual async Task<double> (int channel, CancellationToken ct = default)
{
return await (channel, "FREQ", ct);
}
@@ -261,7 +336,10 @@ namespace DeviceCommand.Devices
/// <summary>
/// 查询指定通道的真均方根电压值 (Vrms)
/// </summary>
public virtual async Task<string> (int channel, CancellationToken ct = default)
/// <param name="channel">通道号 (1 - 4)</param>
/// <param name="ct">取消令牌</param>
/// <returns>真均方根电压值 (Vrms)</returns>
public virtual async Task<double> (int channel, CancellationToken ct = default)
{
return await (channel, "RMS", ct);
}
@@ -269,6 +347,7 @@ namespace DeviceCommand.Devices
#endregion
#region 7.
/// <summary>
/// 【一键获取并保存截图】
/// 自动下发 :PRINt? PNG 指令,接收示波器返回的纯 PNG 二进制流并直接保存到指定路径。
/// </summary>
@@ -279,6 +358,14 @@ namespace DeviceCommand.Devices
{
try
{
string FileDirectory = Path.GetDirectoryName(saveFilePath) ?? ""; // 获取目录
string fileNameWithoutExt = Path.GetFileNameWithoutExtension(saveFilePath); // 获取纯文件名
string extension = Path.GetExtension(saveFilePath); // 获取后缀名 (如 .png)
// 生成时间戳,格式为 年月日_时分秒 (例如 20260817_123045)
string timeStamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
// 组合成新的路径
saveFilePath = Path.Combine(FileDirectory, $"{fileNameWithoutExt}_{timeStamp}{extension}");
// 1. 调用我们在基类 Tcp 中全新实现的 ReadRawAsync
// 它会在内部下发 ":PRINt? PNG\n",自动接收完整的网络字节流
byte[] pureImageBytes = await ReadAllBytesAsync($":PRINt? PNG{ScpiDelimiter}", ct);
+61 -10
View File
@@ -22,11 +22,27 @@ namespace DeviceCommand.Devices
{
}
/// <summary>
/// 辅助方法:解析科学计数法字符串为普通 double。解析失败返回 double.NaN。
/// </summary>
private double ParseScientificResponse(string response)
{
if (string.IsNullOrWhiteSpace(response)) return double.NaN;
// 使用 NumberStyles.Float 允许原生的科学计数法解析 (例如 "1.23E+04")
if (double.TryParse(response.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double result))
{
return result;
}
return double.NaN;
}
#region 1. IEEE 488.2
/// <summary>
/// 清除状态命令。清除标准事件状态寄存器和错误队列
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"*CLS{ScpiDelimiter}", ct);
@@ -42,6 +58,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 读取功率分析仪识别字符串(制造商、产品型号、系统 SN、软件版本号)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>设备标识字符串(制造商, 产品型号, 系统 SN, 软件版本号)</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"*IDN?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -50,6 +68,7 @@ namespace DeviceCommand.Devices
/// <summary>
/// 复位命令。使功率分析仪恢复到出厂默认配置状态
/// </summary>
/// <param name="ct">取消令牌</param>
public virtual async Task (CancellationToken ct = default)
{
await SendAsync($"*RST{ScpiDelimiter}", ct);
@@ -58,6 +77,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 读取标准状态字节寄存器
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>标准状态字节寄存器值</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($"*STB?{ScpiDelimiter}", ScpiDelimiter, ct);
@@ -70,7 +91,10 @@ namespace DeviceCommand.Devices
/// <summary>
/// 查询指定通道的实时 RMS 电压值 (单位: V)
/// </summary>
public virtual async Task<string> (int channel, CancellationToken ct = default)
/// <param name="channel">通道号/单元号</param>
/// <param name="ct">取消令牌</param>
/// <returns>实时 RMS 电压值 (单位: V)</returns>
public virtual async Task<double> (int channel, CancellationToken ct = default)
{
// 1. 先配置 ITEM1 为指定通道的电压有效值 (URMS)
string setItem = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:ITEM1 URMS,{0}{1}", channel, ScpiDelimiter);
@@ -78,13 +102,17 @@ namespace DeviceCommand.Devices
// 2. 再读取 ITEM1 的值
string query = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:VALue? 1{0}", ScpiDelimiter);
return await WriteReadAsync(query, ScpiDelimiter, ct);
string response = await WriteReadAsync(query, ScpiDelimiter, ct);
return ParseScientificResponse(response);
}
/// <summary>
/// 查询指定通道的实时 RMS 电流值 (单位: A)
/// </summary>
public virtual async Task<string> (int channel, CancellationToken ct = default)
/// <param name="channel">通道号/单元号</param>
/// <param name="ct">取消令牌</param>
/// <returns>实时 RMS 电流值 (单位: A)</returns>
public virtual async Task<double> (int channel, CancellationToken ct = default)
{
// 1. 配置 ITEM1 为指定通道的电流有效值 (IRMS)
string setItem = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:ITEM1 IRMS,{0}{1}", channel, ScpiDelimiter);
@@ -92,13 +120,17 @@ namespace DeviceCommand.Devices
// 2. 读取 ITEM1 的值
string query = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:VALue? 1{0}", ScpiDelimiter);
return await WriteReadAsync(query, ScpiDelimiter, ct);
string response = await WriteReadAsync(query, ScpiDelimiter, ct);
return ParseScientificResponse(response);
}
/// <summary>
/// 查询指定通道的实时有功功率值 (单位: W)
/// </summary>
public virtual async Task<string> (int channel, CancellationToken ct = default)
/// <param name="channel">通道号/单元号</param>
/// <param name="ct">取消令牌</param>
/// <returns>实时有功功率值 (单位: W)</returns>
public virtual async Task<double> (int channel, CancellationToken ct = default)
{
// 1. 配置 ITEM1 为指定通道的有功功率 (P)
string setItem = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:ITEM1 P,{0}{1}", channel, ScpiDelimiter);
@@ -106,13 +138,17 @@ namespace DeviceCommand.Devices
// 2. 读取 ITEM1 的值
string query = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:VALue? 1{0}", ScpiDelimiter);
return await WriteReadAsync(query, ScpiDelimiter, ct);
string response = await WriteReadAsync(query, ScpiDelimiter, ct);
return ParseScientificResponse(response);
}
/// <summary>
/// 查询指定通道的实时频率值 (单位: Hz)
/// </summary>
public virtual async Task<string> (int channel, CancellationToken ct = default)
/// <param name="channel">通道号/单元号</param>
/// <param name="ct">取消令牌</param>
/// <returns>实时频率值 (单位: Hz)</returns>
public virtual async Task<double> (int channel, CancellationToken ct = default)
{
// 1. 配置 ITEM1 为指定通道的电压频率 (FU)
string setItem = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:ITEM1 FU,{0}{1}", channel, ScpiDelimiter);
@@ -120,13 +156,17 @@ namespace DeviceCommand.Devices
// 2. 读取 ITEM1 的值
string query = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:VALue? 1{0}", ScpiDelimiter);
return await WriteReadAsync(query, ScpiDelimiter, ct);
string response = await WriteReadAsync(query, ScpiDelimiter, ct);
return ParseScientificResponse(response);
}
/// <summary>
/// 查询指定通道的功率因数 (Power Factor)
/// </summary>
public virtual async Task<string> (int channel, CancellationToken ct = default)
/// <param name="channel">通道号/单元号</param>
/// <param name="ct">取消令牌</param>
/// <returns>功率因数值</returns>
public virtual async Task<double> (int channel, CancellationToken ct = default)
{
// 1. 配置 ITEM1 为指定通道的功率因数 (LAMBda)
string setItem = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:ITEM1 LAMBda,{0}{1}", channel, ScpiDelimiter);
@@ -134,7 +174,8 @@ namespace DeviceCommand.Devices
// 2. 读取 ITEM1 的值
string query = string.Format(CultureInfo.InvariantCulture, ":NUMeric:NORMal:VALue? 1{0}", ScpiDelimiter);
return await WriteReadAsync(query, ScpiDelimiter, ct);
string response = await WriteReadAsync(query, ScpiDelimiter, ct);
return ParseScientificResponse(response);
}
#endregion
@@ -204,6 +245,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 14. 查询仪器型号名称
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>仪器型号名称字符串</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($":SYSTem:MODel?{ScpiDelimiter}", ScpiDelimiter, ct); //
@@ -212,6 +255,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 16. 查询仪器唯一序列号
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>仪器唯一序列号字符串</returns>
public virtual async Task<string> (CancellationToken ct = default)
{
return await WriteReadAsync($":SYSTem:SERial?{ScpiDelimiter}", ScpiDelimiter, ct); //
@@ -221,6 +266,7 @@ namespace DeviceCommand.Devices
/// 15. 设置数值数据显示的分辨率 (5位或6位)
/// </summary>
/// <param name="resolution">有效值只能为 5 或 6</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int resolution, CancellationToken ct = default)
{
if (resolution != 5 && resolution != 6) throw new ArgumentException("分辨率只能设置为 5 或 6 位");
@@ -230,6 +276,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 13. 设置或查询屏幕 LCD 的亮度级别 (1-10)
/// </summary>
/// <param name="brightness">亮度级别 (范围: 1 - 10)</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (int brightness, CancellationToken ct = default)
{
if (brightness < 1 || brightness > 10) throw new ArgumentOutOfRangeException(nameof(brightness), "亮度范围必须在 1~10 之间");
@@ -239,6 +287,8 @@ namespace DeviceCommand.Devices
/// <summary>
/// 18. 设置或查询触摸锁的开/关状态 (锁定时防止人工误触触控屏)
/// </summary>
/// <param name="isLocked">true: 锁定触摸, false: 解锁触摸</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (bool isLocked, CancellationToken ct = default)
{
string state = isLocked ? "ON" : "OFF";
@@ -249,6 +299,7 @@ namespace DeviceCommand.Devices
/// 17. 设置或读取分析仪当前的系统内部时间
/// </summary>
/// <param name="timeStr">格式必须为 "HH:MM:SS"</param>
/// <param name="ct">取消令牌</param>
public virtual async Task (string timeStr, CancellationToken ct = default)
{
await SendAsync($":SYSTem:TIME \"{timeStr}\"{ScpiDelimiter}", ct); //
+88 -13
View File
@@ -2,17 +2,31 @@
using NModbus;
using NModbus.Serial;
using System;
using System.Collections.Concurrent;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
namespace DeviceCommand.Flexible
{
/// <summary>
/// 灵活型 Modbus RTU 串口通信类(免实例化,每次调用临时创建串口连接)。
/// 支持保持寄存器与线圈的读写操作,内部使用按串口名称粒度的通信锁保证同一串口同一时刻只有一个事务在执行,
/// 不同串口之间互不阻塞,适用于偶发性、无需保持长连接的 Modbus RTU 设备读写场景。
/// </summary>
[ADPCommand]
public static class FModbusRTU
{
private static readonly SemaphoreSlim _commLock = new(1, 1);
// 按串口名称粒度的通信锁:同一串口同一时刻只有一个事务在执行,不同串口互不阻塞
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _commLocks = new();
/// <summary>获取指定串口的通信锁(不存在则自动创建)</summary>
private static SemaphoreSlim GetLock(string portName)
=> _commLocks.GetOrAdd(portName, _ => new SemaphoreSlim(1, 1));
/// <summary>
/// 创建串口实例并配置超时参数。
/// </summary>
private static SerialPort CreatePort(
string portName,
int baudRate,
@@ -29,6 +43,9 @@ namespace DeviceCommand.Flexible
};
}
/// <summary>
/// 基于指定串口创建 Modbus RTU 主站。
/// </summary>
private static IModbusMaster CreateMaster(SerialPort port)
{
return new ModbusFactory().CreateRtuMaster(port);
@@ -36,7 +53,22 @@ namespace DeviceCommand.Flexible
#region Holding Register
public static async Task<ushort[]> ReadHoldingRegistersAsync(
/// <summary>
/// 读取保持寄存器(功能码 03),返回指定数量的寄存器值数组。
/// </summary>
/// <param name="portName">串口名称,如 "COM1"</param>
/// <param name="baudRate">波特率,如 9600、115200</param>
/// <param name="slaveAddress">Modbus 从站站号 (1-247)</param>
/// <param name="startAddress">起始寄存器地址</param>
/// <param name="numberOfPoints">读取的寄存器数量</param>
/// <param name="dataBits">数据位,默认 8</param>
/// <param name="stopBits">停止位,默认 1 位</param>
/// <param name="parity">校验位,默认无校验</param>
/// <param name="readTimeout">读取超时时间(毫秒),默认 3000</param>
/// <param name="writeTimeout">写入超时时间(毫秒),默认 3000</param>
/// <param name="ct">异步取消令牌</param>
/// <returns>读取到的寄存器值数组</returns>
public static async Task<ushort[]> (
string portName,
int baudRate,
byte slaveAddress,
@@ -49,7 +81,7 @@ namespace DeviceCommand.Flexible
int writeTimeout = 3000,
CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(portName).WaitAsync(ct);
try
{
using var port = CreatePort(
@@ -69,11 +101,25 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(portName).Release();
}
}
public static async Task WriteSingleRegisterAsync(
/// <summary>
/// 写入单个保持寄存器(功能码 06)。
/// </summary>
/// <param name="portName">串口名称,如 "COM1"</param>
/// <param name="baudRate">波特率,如 9600、115200</param>
/// <param name="slaveAddress">Modbus 从站站号 (1-247)</param>
/// <param name="registerAddress">要写入的寄存器地址</param>
/// <param name="value">要写入的寄存器值 (0-65535)</param>
/// <param name="dataBits">数据位,默认 8</param>
/// <param name="stopBits">停止位,默认 1 位</param>
/// <param name="parity">校验位,默认无校验</param>
/// <param name="readTimeout">读取超时时间(毫秒),默认 3000</param>
/// <param name="writeTimeout">写入超时时间(毫秒),默认 3000</param>
/// <param name="ct">异步取消令牌</param>
public static async Task (
string portName,
int baudRate,
byte slaveAddress,
@@ -86,7 +132,7 @@ namespace DeviceCommand.Flexible
int writeTimeout = 3000,
CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(portName).WaitAsync(ct);
try
{
using var port = CreatePort(
@@ -106,7 +152,7 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(portName).Release();
}
}
@@ -114,7 +160,22 @@ namespace DeviceCommand.Flexible
#region Coil
public static async Task<bool[]> ReadCoilsAsync(
/// <summary>
/// 读取线圈状态(功能码 01),返回指定数量的线圈开关状态数组。
/// </summary>
/// <param name="portName">串口名称,如 "COM1"</param>
/// <param name="baudRate">波特率,如 9600、115200</param>
/// <param name="slaveAddress">Modbus 从站站号 (1-247)</param>
/// <param name="startAddress">起始线圈地址</param>
/// <param name="numberOfPoints">读取的线圈数量</param>
/// <param name="dataBits">数据位,默认 8</param>
/// <param name="stopBits">停止位,默认 1 位</param>
/// <param name="parity">校验位,默认无校验</param>
/// <param name="readTimeout">读取超时时间(毫秒),默认 3000</param>
/// <param name="writeTimeout">写入超时时间(毫秒),默认 3000</param>
/// <param name="ct">异步取消令牌</param>
/// <returns>线圈状态数组,true 表示吸合/导通</returns>
public static async Task<bool[]> 线(
string portName,
int baudRate,
byte slaveAddress,
@@ -127,7 +188,7 @@ namespace DeviceCommand.Flexible
int writeTimeout = 3000,
CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(portName).WaitAsync(ct);
try
{
using var port = CreatePort(
@@ -147,11 +208,25 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(portName).Release();
}
}
public static async Task WriteSingleCoilAsync(
/// <summary>
/// 写入单个线圈(功能码 05),控制单个开关量输出。
/// </summary>
/// <param name="portName">串口名称,如 "COM1"</param>
/// <param name="baudRate">波特率,如 9600、115200</param>
/// <param name="slaveAddress">Modbus 从站站号 (1-247)</param>
/// <param name="coilAddress">要写入的线圈地址</param>
/// <param name="value">线圈状态(true: 吸合/导通, false: 断开)</param>
/// <param name="dataBits">数据位,默认 8</param>
/// <param name="stopBits">停止位,默认 1 位</param>
/// <param name="parity">校验位,默认无校验</param>
/// <param name="readTimeout">读取超时时间(毫秒),默认 3000</param>
/// <param name="writeTimeout">写入超时时间(毫秒),默认 3000</param>
/// <param name="ct">异步取消令牌</param>
public static async Task 线(
string portName,
int baudRate,
byte slaveAddress,
@@ -164,7 +239,7 @@ namespace DeviceCommand.Flexible
int writeTimeout = 3000,
CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(portName).WaitAsync(ct);
try
{
using var port = CreatePort(
@@ -184,7 +259,7 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(portName).Release();
}
}
+73 -13
View File
@@ -1,6 +1,7 @@
using Common.Attributes;
using NModbus;
using System;
using System.Collections.Concurrent;
using System.Net.Sockets;
using System.Text;
using System.Threading;
@@ -8,11 +9,24 @@ using System.Threading.Tasks;
namespace DeviceCommand.Flexible
{
/// <summary>
/// 灵活型 Modbus TCP 通信类(免实例化,每次调用临时建立 TCP 连接)。
/// 支持保持寄存器与线圈的读写操作,内部使用按端点粒度的通信锁保证同一端点同一时刻只有一个事务在执行,
/// 不同端点之间互不阻塞,适用于偶发性、无需保持长连接的 Modbus TCP 设备读写场景。
/// </summary>
[ADPCommand]
public static class FModbusTCP
{
private static readonly SemaphoreSlim _commLock = new(1, 1);
// 按端点粒度的通信锁:同一端点同一时刻只有一个事务在执行,不同端点互不阻塞
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _commLocks = new();
/// <summary>获取指定端点的通信锁(不存在则自动创建)</summary>
private static SemaphoreSlim GetLock(string ipAddress, int port)
=> _commLocks.GetOrAdd($"{ipAddress}:{port}", _ => new SemaphoreSlim(1, 1));
/// <summary>
/// 建立 TCP 连接并创建 Modbus TCP 主站。
/// </summary>
private static async Task<IModbusMaster> ConnectAsync(string ipAddress, int port, int sendTimeout, int receiveTimeout, CancellationToken ct)
{
var tcpClient = new TcpClient();
@@ -26,7 +40,19 @@ namespace DeviceCommand.Flexible
#region Holding Registers
public static async Task<ushort[]> ReadHoldingRegistersAsync(
/// <summary>
/// 读取保持寄存器(功能码 03),返回指定数量的寄存器值数组。
/// </summary>
/// <param name="ipAddress">设备 IP 地址</param>
/// <param name="port">Modbus TCP 端口号,默认常用 502</param>
/// <param name="slaveAddress">Modbus 从站站号(单元标识符)</param>
/// <param name="startAddress">起始寄存器地址</param>
/// <param name="numberOfPoints">读取的寄存器数量</param>
/// <param name="sendTimeout">发送/连接超时时间(毫秒),默认 3000</param>
/// <param name="receiveTimeout">接收超时时间(毫秒),默认 3000</param>
/// <param name="ct">异步取消令牌</param>
/// <returns>读取到的寄存器值数组</returns>
public static async Task<ushort[]> (
string ipAddress,
int port,
byte slaveAddress,
@@ -36,7 +62,7 @@ namespace DeviceCommand.Flexible
int receiveTimeout = 3000,
CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(ipAddress, port).WaitAsync(ct);
try
{
using var master = await ConnectAsync(ipAddress, port, sendTimeout, receiveTimeout, ct) as IDisposable;
@@ -47,11 +73,22 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(ipAddress, port).Release();
}
}
public static async Task WriteSingleRegisterAsync(
/// <summary>
/// 写入单个保持寄存器(功能码 06)。
/// </summary>
/// <param name="ipAddress">设备 IP 地址</param>
/// <param name="port">Modbus TCP 端口号,默认常用 502</param>
/// <param name="slaveAddress">Modbus 从站站号(单元标识符)</param>
/// <param name="registerAddress">要写入的寄存器地址</param>
/// <param name="value">要写入的寄存器值 (0-65535)</param>
/// <param name="sendTimeout">发送/连接超时时间(毫秒),默认 3000</param>
/// <param name="receiveTimeout">接收超时时间(毫秒),默认 3000</param>
/// <param name="ct">异步取消令牌</param>
public static async Task (
string ipAddress,
int port,
byte slaveAddress,
@@ -61,7 +98,7 @@ namespace DeviceCommand.Flexible
int receiveTimeout = 3000,
CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(ipAddress, port).WaitAsync(ct);
try
{
using var master = await ConnectAsync(ipAddress, port, sendTimeout, receiveTimeout, ct) as IDisposable;
@@ -71,7 +108,7 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(ipAddress, port).Release();
}
}
@@ -79,7 +116,19 @@ namespace DeviceCommand.Flexible
#region Coils
public static async Task<bool[]> ReadCoilsAsync(
/// <summary>
/// 读取线圈状态(功能码 01),返回指定数量的线圈开关状态数组。
/// </summary>
/// <param name="ipAddress">设备 IP 地址</param>
/// <param name="port">Modbus TCP 端口号,默认常用 502</param>
/// <param name="slaveAddress">Modbus 从站站号(单元标识符)</param>
/// <param name="startAddress">起始线圈地址</param>
/// <param name="numberOfPoints">读取的线圈数量</param>
/// <param name="sendTimeout">发送/连接超时时间(毫秒),默认 3000</param>
/// <param name="receiveTimeout">接收超时时间(毫秒),默认 3000</param>
/// <param name="ct">异步取消令牌</param>
/// <returns>线圈状态数组,true 表示吸合/导通</returns>
public static async Task<bool[]> 线(
string ipAddress,
int port,
byte slaveAddress,
@@ -89,7 +138,7 @@ namespace DeviceCommand.Flexible
int receiveTimeout = 3000,
CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(ipAddress, port).WaitAsync(ct);
try
{
using var master = await ConnectAsync(ipAddress, port, sendTimeout, receiveTimeout, ct) as IDisposable;
@@ -100,11 +149,22 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(ipAddress, port).Release();
}
}
public static async Task WriteSingleCoilAsync(
/// <summary>
/// 写入单个线圈(功能码 05),控制单个开关量输出。
/// </summary>
/// <param name="ipAddress">设备 IP 地址</param>
/// <param name="port">Modbus TCP 端口号,默认常用 502</param>
/// <param name="slaveAddress">Modbus 从站站号(单元标识符)</param>
/// <param name="coilAddress">要写入的线圈地址</param>
/// <param name="value">线圈状态(true: 吸合/导通, false: 断开)</param>
/// <param name="sendTimeout">发送/连接超时时间(毫秒),默认 3000</param>
/// <param name="receiveTimeout">接收超时时间(毫秒),默认 3000</param>
/// <param name="ct">异步取消令牌</param>
public static async Task 线(
string ipAddress,
int port,
byte slaveAddress,
@@ -114,7 +174,7 @@ namespace DeviceCommand.Flexible
int receiveTimeout = 3000,
CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(ipAddress, port).WaitAsync(ct);
try
{
using var master = await ConnectAsync(ipAddress, port, sendTimeout, receiveTimeout, ct) as IDisposable;
@@ -124,7 +184,7 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(ipAddress, port).Release();
}
}
+48 -7
View File
@@ -1,5 +1,6 @@
using Common.Attributes;
using System;
using System.Collections.Concurrent;
using System.IO.Ports;
using System.Text;
using System.Threading;
@@ -7,11 +8,25 @@ using System.Threading.Tasks;
namespace DeviceCommand.Flexible
{
/// <summary>
/// 灵活型串口通信类(免实例化,每次调用临时创建串口连接)。
/// 支持只发送指令、发送并读取应答两种最常用操作,
/// 内部使用按串口名称粒度的通信锁保证同一串口同一时刻只有一个事务在执行,
/// 不同串口之间互不阻塞,适用于偶发性、无需保持长连接的串口设备通信场景(如示波器、电源的 SCPI 指令)。
/// </summary>
[ADPCommand]
public static class FSerialPort
{
private static readonly SemaphoreSlim _commLock = new(1, 1);
// 按串口名称粒度的通信锁:同一串口同一时刻只有一个事务在执行,不同串口互不阻塞
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _commLocks = new();
/// <summary>获取指定串口的通信锁(不存在则自动创建)</summary>
private static SemaphoreSlim GetLock(string portName)
=> _commLocks.GetOrAdd(portName, _ => new SemaphoreSlim(1, 1));
/// <summary>
/// 创建串口实例并配置 UTF8 编码与超时参数。
/// </summary>
private static SerialPort CreatePort(string portName, int baudRate, Parity parity, int dataBits, StopBits stopBits, int sendTimeout,int receiveTimeout)
{
return new SerialPort(portName, baudRate, parity, dataBits, stopBits)
@@ -24,9 +39,21 @@ namespace DeviceCommand.Flexible
#region
public static async Task SendAsync(string portName,int baudRate,Parity parity,int dataBits,StopBits stopBits,int sendTimeout,int receiveTimeout,string command,CancellationToken ct = default)
/// <summary>
/// 向串口发送一条字符串指令(只发送,不等待应答)。
/// </summary>
/// <param name="portName">串口名称,如 "COM1"</param>
/// <param name="baudRate">波特率,如 9600、115200</param>
/// <param name="parity">校验位</param>
/// <param name="dataBits">数据位,常用 8</param>
/// <param name="stopBits">停止位</param>
/// <param name="sendTimeout">发送超时时间(毫秒)</param>
/// <param name="receiveTimeout">接收超时时间(毫秒)</param>
/// <param name="command">要发送的指令字符串</param>
/// <param name="ct">异步取消令牌</param>
public static async Task (string portName,int baudRate,Parity parity,int dataBits,StopBits stopBits,int sendTimeout,int receiveTimeout,string command,CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(portName).WaitAsync(ct);
try
{
using var port = CreatePort(
@@ -44,7 +71,7 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(portName).Release();
}
}
@@ -52,9 +79,23 @@ namespace DeviceCommand.Flexible
#region
public static async Task<string> SendReadAsync(string portName,int baudRate, Parity parity, int dataBits, StopBits stopBits,int sendTimeout, int receiveTimeout,string command,string delimiter = "\n", CancellationToken ct = default)
/// <summary>
/// 向串口发送一条字符串指令并读取应答,读取到结束符为止(应答不含结束符)。
/// </summary>
/// <param name="portName">串口名称,如 "COM1"</param>
/// <param name="baudRate">波特率,如 9600、115200</param>
/// <param name="parity">校验位</param>
/// <param name="dataBits">数据位,常用 8</param>
/// <param name="stopBits">停止位</param>
/// <param name="sendTimeout">发送超时时间(毫秒)</param>
/// <param name="receiveTimeout">接收超时时间(毫秒),超时未收到结束符抛出超时异常</param>
/// <param name="command">要发送的指令字符串</param>
/// <param name="delimiter">应答结束符,默认换行符 "\n"</param>
/// <param name="ct">异步取消令牌</param>
/// <returns>去除结束符并去除首尾空白后的应答字符串</returns>
public static async Task<string> (string portName,int baudRate, Parity parity, int dataBits, StopBits stopBits,int sendTimeout, int receiveTimeout,string command,string delimiter = "\n", CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(portName).WaitAsync(ct);
try
{
using var port = CreatePort(
@@ -95,7 +136,7 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(portName).Release();
}
}
+58 -12
View File
@@ -1,19 +1,39 @@
using Common.Attributes;
using System.Collections.Concurrent;
using System.Net.Sockets;
using System.Text;
namespace DeviceCommand.Flexible
{
/// <summary>
/// 灵活型 TCP 通信类(免实例化,每次调用临时建立 TCP 连接)。
/// 支持字节/文本发送、定长字节读取、按结束符读取文本行四种操作,
/// 内部使用按端点粒度的通信锁保证同一端点同一时刻只有一个 TCP 事务在执行,
/// 不同端点之间互不阻塞,适用于偶发性、无需保持长连接的 TCP 设备通信场景。
/// </summary>
[ADPCommand]
public static class FTCP
{
private static readonly SemaphoreSlim _commLock = new(1, 1);
// 按端点粒度的通信锁:同一端点同一时刻只有一个事务在执行,不同端点互不阻塞
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _commLocks = new();
/// <summary>获取指定端点的通信锁(不存在则自动创建)</summary>
private static SemaphoreSlim GetLock(string ipAddress, int port)
=> _commLocks.GetOrAdd($"{ipAddress}:{port}", _ => new SemaphoreSlim(1, 1));
#region Send
public static async Task SendAsync(string ipAddress,int port,int sendTimeout, byte[] buffer, CancellationToken ct = default)
/// <summary>
/// 向指定 TCP 端点发送一段字节数据(发送完成后立即断开)。
/// </summary>
/// <param name="ipAddress">设备 IP 地址</param>
/// <param name="port">TCP 端口号</param>
/// <param name="sendTimeout">发送超时时间(毫秒)</param>
/// <param name="buffer">要发送的字节数组</param>
/// <param name="ct">异步取消令牌</param>
public static async Task (string ipAddress,int port,int sendTimeout, byte[] buffer, CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(ipAddress, port).WaitAsync(ct);
try
{
using var client = new TcpClient();
@@ -26,22 +46,39 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(ipAddress, port).Release();
}
}
public static Task SendAsync(string ipAddress, int port,int sendTimeout, string text,CancellationToken ct = default)
/// <summary>
/// 向指定 TCP 端点发送一段文本(以 UTF8 编码为字节后发送,发送完成后立即断开)。
/// </summary>
/// <param name="ipAddress">设备 IP 地址</param>
/// <param name="port">TCP 端口号</param>
/// <param name="sendTimeout">发送超时时间(毫秒)</param>
/// <param name="text">要发送的文本字符串</param>
/// <param name="ct">异步取消令牌</param>
public static Task (string ipAddress, int port,int sendTimeout, string text,CancellationToken ct = default)
{
return SendAsync( ipAddress, port, sendTimeout, Encoding.UTF8.GetBytes(text),ct);
return ( ipAddress, port, sendTimeout, Encoding.UTF8.GetBytes(text),ct);
}
#endregion
#region Read
public static async Task<byte[]> ReadAsync(string ipAddress,int port,int receiveTimeout,int length,CancellationToken ct = default)
/// <summary>
/// 连接指定 TCP 端点并读取指定长度的字节数据(连接关闭或读满为止)。
/// </summary>
/// <param name="ipAddress">设备 IP 地址</param>
/// <param name="port">TCP 端口号</param>
/// <param name="receiveTimeout">接收超时时间(毫秒)</param>
/// <param name="length">要读取的字节长度</param>
/// <param name="ct">异步取消令牌</param>
/// <returns>实际读取到的字节数组(对端提前关闭时可能短于请求长度)</returns>
public static async Task<byte[]> (string ipAddress,int port,int receiveTimeout,int length,CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(ipAddress, port).WaitAsync(ct);
try
{
using var client = new TcpClient();
@@ -68,13 +105,22 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(ipAddress, port).Release();
}
}
public static async Task<string> ReadLineAsync( string ipAddress, int port, int receiveTimeout, string delimiter = "\n",CancellationToken ct = default)
/// <summary>
/// 连接指定 TCP 端点并读取一行文本,读取到结束符为止(返回内容不含结束符)。
/// </summary>
/// <param name="ipAddress">设备 IP 地址</param>
/// <param name="port">TCP 端口号</param>
/// <param name="receiveTimeout">接收超时时间(毫秒),超时未收到结束符抛出超时异常</param>
/// <param name="delimiter">文本行结束符,默认换行符 "\n"</param>
/// <param name="ct">异步取消令牌</param>
/// <returns>去除结束符并去除首尾空白后的文本行</returns>
public static async Task<string> ( string ipAddress, int port, int receiveTimeout, string delimiter = "\n",CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
await GetLock(ipAddress, port).WaitAsync(ct);
try
{
using var client = new TcpClient();
@@ -106,7 +152,7 @@ namespace DeviceCommand.Flexible
}
finally
{
_commLock.Release();
GetLock(ipAddress, port).Release();
}
}
@@ -120,29 +120,29 @@ namespace DeviceEditModule.ViewModels
#region
private string _measuredVoltage = "—";
public string MeasuredVoltage
private double _measuredVoltage;
public double MeasuredVoltage
{
get => _measuredVoltage;
set => SetProperty(ref _measuredVoltage, value);
}
private string _measuredCurrent = "—";
public string MeasuredCurrent
private double _measuredCurrent;
public double MeasuredCurrent
{
get => _measuredCurrent;
set => SetProperty(ref _measuredCurrent, value);
}
private string _measuredPower = "—";
public string MeasuredPower
private double _measuredPower;
public double MeasuredPower
{
get => _measuredPower;
set => SetProperty(ref _measuredPower, value);
}
private string _measuredFrequency = "—";
public string MeasuredFrequency
private double _measuredFrequency;
public double MeasuredFrequency
{
get => _measuredFrequency;
set => SetProperty(ref _measuredFrequency, value);
@@ -118,22 +118,22 @@ namespace DeviceEditModule.ViewModels
#region
private string _measuredVoltage = "—";
public string MeasuredVoltage
private double _measuredVoltage;
public double MeasuredVoltage
{
get => _measuredVoltage;
set => SetProperty(ref _measuredVoltage, value);
}
private string _measuredCurrent = "—";
public string MeasuredCurrent
private double _measuredCurrent;
public double MeasuredCurrent
{
get => _measuredCurrent;
set => SetProperty(ref _measuredCurrent, value);
}
private string _measuredPower = "—";
public string MeasuredPower
private double _measuredPower;
public double MeasuredPower
{
get => _measuredPower;
set => SetProperty(ref _measuredPower, value);
@@ -93,22 +93,22 @@ namespace DeviceEditModule.ViewModels
#region
private string _measuredVoltage = "—";
public string MeasuredVoltage
private double _measuredVoltage;
public double MeasuredVoltage
{
get => _measuredVoltage;
set => SetProperty(ref _measuredVoltage, value);
}
private string _measuredCurrent = "—";
public string MeasuredCurrent
private double _measuredCurrent;
public double MeasuredCurrent
{
get => _measuredCurrent;
set => SetProperty(ref _measuredCurrent, value);
}
private string _measuredPower = "—";
public string MeasuredPower
private double _measuredPower;
public double MeasuredPower
{
get => _measuredPower;
set => SetProperty(ref _measuredPower, value);
@@ -94,22 +94,22 @@ namespace DeviceEditModule.ViewModels
#region
private string _measuredVoltage = "—";
public string MeasuredVoltage
private double _measuredVoltage;
public double MeasuredVoltage
{
get => _measuredVoltage;
set => SetProperty(ref _measuredVoltage, value);
}
private string _measuredCurrent = "—";
public string MeasuredCurrent
private double _measuredCurrent;
public double MeasuredCurrent
{
get => _measuredCurrent;
set => SetProperty(ref _measuredCurrent, value);
}
private string _measuredPower = "—";
public string MeasuredPower
private double _measuredPower;
public double MeasuredPower
{
get => _measuredPower;
set => SetProperty(ref _measuredPower, value);
@@ -3,7 +3,6 @@ using Prism.Commands;
using Prism.Ioc;
using System;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
@@ -113,22 +112,22 @@ namespace DeviceEditModule.ViewModels
#region
private string _measuredVpp = "—";
public string MeasuredVpp
private double _measuredVpp;
public double MeasuredVpp
{
get => _measuredVpp;
set => SetProperty(ref _measuredVpp, value);
}
private string _measuredFrequency = "—";
public string MeasuredFrequency
private double _measuredFrequency;
public double MeasuredFrequency
{
get => _measuredFrequency;
set => SetProperty(ref _measuredFrequency, value);
}
private string _measuredRms = "—";
public string MeasuredRms
private double _measuredRms;
public double MeasuredRms
{
get => _measuredRms;
set => SetProperty(ref _measuredRms, value);
@@ -207,40 +206,30 @@ namespace DeviceEditModule.ViewModels
QueryMeasurementsCommand = new DelegateCommand(async () => await Exec(async () =>
{
// 1. 保留设备原生吐出的完整原始字符串
string rawVpp = await _device!.(Channel, Ct());
string rawFreq = await _device!.(Channel, Ct());
string rawRms = await _device!.(Channel, Ct());
// 设备层已自动提取纯数值并返回 double
MeasuredVpp = await _device!.(Channel, Ct());
MeasuredFrequency = await _device!.(Channel, Ct());
MeasuredRms = await _device!.(Channel, Ct());
// 2. 清洗数据并将科学计数法转为常规小数后赋值给 UI 属性
MeasuredVpp = ParseMeasurement(rawVpp);
MeasuredFrequency = ParseMeasurement(rawFreq);
MeasuredRms = ParseMeasurement(rawRms);
// 3. 在日志中同时体现设备原始报文与解析呈现值,极方便联调
AppendLog($"C{Channel} 测量原始数据 → Vpp:{rawVpp.Trim()} Freq:{rawFreq.Trim()} RMS:{rawRms.Trim()}");
AppendLog($"C{Channel} 界面呈现数值 → Vpp:{MeasuredVpp}V Freq:{MeasuredFrequency}Hz RMS:{MeasuredRms}V");
AppendLog($"C{Channel} 测量结果 → Vpp:{MeasuredVpp:0.######}V Freq:{MeasuredFrequency:0.######}Hz RMS:{MeasuredRms:0.######}V");
}));
QueryVppCommand = new DelegateCommand(async () => await Exec(async () =>
{
string raw = await _device!.(Channel, Ct());
MeasuredVpp = ParseMeasurement(raw);
AppendLog($"C{Channel} Vpp: {MeasuredVpp} (Raw: {raw.Trim()})");
MeasuredVpp = await _device!.(Channel, Ct());
AppendLog($"C{Channel} Vpp: {MeasuredVpp:0.######} V");
}));
QueryFrequencyCommand = new DelegateCommand(async () => await Exec(async () =>
{
string raw = await _device!.(Channel, Ct());
MeasuredFrequency = ParseMeasurement(raw);
AppendLog($"C{Channel} Freq: {MeasuredFrequency} (Raw: {raw.Trim()})");
MeasuredFrequency = await _device!.(Channel, Ct());
AppendLog($"C{Channel} Freq: {MeasuredFrequency:0.######} Hz");
}));
QueryRmsCommand = new DelegateCommand(async () => await Exec(async () =>
{
string raw = await _device!.(Channel, Ct());
MeasuredRms = ParseMeasurement(raw);
AppendLog($"C{Channel} RMS: {MeasuredRms} (Raw: {raw.Trim()})");
MeasuredRms = await _device!.(Channel, Ct());
AppendLog($"C{Channel} RMS: {MeasuredRms:0.######} V");
}));
Initialize();
@@ -330,52 +319,6 @@ namespace DeviceEditModule.ViewModels
: line + "\n" + ResponseLog;
}
/// <summary>
/// 清洗示波器原始返回的测量字符串(例如 "C1:PAVA RMS,5.57E-03V")并安全转化为无科学计数法的小数形式。
/// </summary>
/// <param name="rawResponse">设备原始应答数据</param>
/// <returns>可直接绑定到 UI 呈现的字符串数字</returns>
private string ParseMeasurement(string rawResponse)
{
if (string.IsNullOrWhiteSpace(rawResponse)) return "—";
try
{
string cleanData = rawResponse.Trim();
// 1. 斩断报头,提取逗号后面的具体内容(如 "5.57E-03V" 或 "****"
int commaIndex = cleanData.IndexOf(',');
if (commaIndex == -1) return "—";
string valStr = cleanData.Substring(commaIndex + 1);
var match = Regex.Match(valStr, @"[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?");
if (match.Success)
{
valStr = match.Value;
}
// 3. 校验并拦截设备未测出时的无效星号 "****"
if (valStr.Contains("*") || string.IsNullOrWhiteSpace(valStr))
{
return "0"; // 回归为零或 "—",防止触发数据异常
}
// 4. 解析科学计数法,并重新以不带科学计数法的小数样式展开
if (double.TryParse(valStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double result))
{
// "0.######" 样式会自动消除末尾无用的冗余零,并显示为普通小数(如 0.00557)
return result.ToString("0.######", CultureInfo.InvariantCulture);
}
}
catch
{
// 捕获可能产生的边缘转换故障,保证轮询线程绝不崩溃
}
return "—";
}
#endregion
public void Dispose()
@@ -101,36 +101,36 @@ namespace DeviceEditModule.ViewModels
#region
private string _measuredVoltage = "—";
public string MeasuredVoltage
private double _measuredVoltage;
public double MeasuredVoltage
{
get => _measuredVoltage;
set => SetProperty(ref _measuredVoltage, value);
}
private string _measuredCurrent = "—";
public string MeasuredCurrent
private double _measuredCurrent;
public double MeasuredCurrent
{
get => _measuredCurrent;
set => SetProperty(ref _measuredCurrent, value);
}
private string _measuredPower = "—";
public string MeasuredPower
private double _measuredPower;
public double MeasuredPower
{
get => _measuredPower;
set => SetProperty(ref _measuredPower, value);
}
private string _measuredFrequency = "—";
public string MeasuredFrequency
private double _measuredFrequency;
public double MeasuredFrequency
{
get => _measuredFrequency;
set => SetProperty(ref _measuredFrequency, value);
}
private string _measuredPowerFactor = "—";
public string MeasuredPowerFactor
private double _measuredPowerFactor;
public double MeasuredPowerFactor
{
get => _measuredPowerFactor;
set => SetProperty(ref _measuredPowerFactor, value);
@@ -196,20 +196,14 @@ namespace DeviceEditModule.ViewModels
// 只修改这里:保持你原本的 5 次驱动查询调用不变,仅对读回来的科学计数法进行两位小数格式化
QueryAllMeasureCommand = new DelegateCommand(async () => await Exec(async () =>
{
string rawU = await _device!.(Channel, Ct());
string rawI = await _device!.(Channel, Ct());
string rawP = await _device!.(Channel, Ct());
string rawF = await _device!.(Channel, Ct());
string rawPF = await _device!.(Channel, Ct());
// 设备层已返回 double 数值
MeasuredVoltage = await _device!.(Channel, Ct());
MeasuredCurrent = await _device!.(Channel, Ct());
MeasuredPower = await _device!.(Channel, Ct());
MeasuredFrequency = await _device!.(Channel, Ct());
MeasuredPowerFactor = await _device!.(Channel, Ct());
// 转换科学计数法格式,如果失败会自动保持原样
MeasuredVoltage = FormatToDecimal(rawU, "F2"); // 电压两位小数
MeasuredCurrent = FormatToDecimal(rawI, "F3"); // 电流通常较小,建议保留3位小数
MeasuredPower = FormatToDecimal(rawP, "F2"); // 功率两位小数
MeasuredFrequency = FormatToDecimal(rawF, "F2"); // 频率两位小数
MeasuredPowerFactor = FormatToDecimal(rawPF, "F3"); // 功率因数保留3位小数
AppendLog($"CH{Channel} 测量 → U:{MeasuredVoltage}V I:{MeasuredCurrent}A P:{MeasuredPower}W F:{MeasuredFrequency}Hz PF:{MeasuredPowerFactor}");
AppendLog($"CH{Channel} 测量 → U:{MeasuredVoltage:F2}V I:{MeasuredCurrent:F3}A P:{MeasuredPower:F2}W F:{MeasuredFrequency:F2}Hz PF:{MeasuredPowerFactor:F3}");
}));
Initialize();
@@ -263,41 +257,6 @@ namespace DeviceEditModule.ViewModels
private CancellationToken Ct() => (_cts = new CancellationTokenSource(TimeSpan.FromSeconds(10))).Token;
/// <summary>
/// 将科学计数法字符串转换为标准小数格式
/// </summary>
private string FormatToDecimal(string rawInput, string decimalFormat = "F2")
{
if (string.IsNullOrWhiteSpace(rawInput))
return "—";
// 去除可能夹杂的命令头,保留数据部分
string cleanInput = CleanResponseHeader(rawInput);
// 解析科学计数法(NumberStyles.Any 和 InvariantCulture 是关键)
if (double.TryParse(cleanInput, NumberStyles.Any, CultureInfo.InvariantCulture, out double value))
{
return value.ToString(decimalFormat, CultureInfo.InvariantCulture);
}
return cleanInput;
}
/// <summary>
/// 辅助清洗:剥离可能伴随吐回的命令头或双引号
/// </summary>
private string CleanResponseHeader(string response)
{
if (string.IsNullOrWhiteSpace(response)) return string.Empty;
string result = response.Trim();
if (result.Contains(" "))
{
int lastSpaceIndex = result.LastIndexOf(' ');
result = result.Substring(lastSpaceIndex + 1).Trim();
}
return result.Replace("\"", "").Replace("'", "").Trim();
}
private async Task Exec(Func<Task> action)
{
if (_device == null)
+183
View File
@@ -82,19 +82,23 @@ namespace ExportModule.ViewModels
public ICommand LoadedCommand { get; }
public ICommand QueryCommand { get; }
public ICommand ExportCommand { get; }
public ICommand ExportReportCommand { get; }
#endregion
#region
private readonly ITestReportService _testReportService;
private readonly ITestCheckRecordService _testCheckRecordService;
#endregion
public ExportViewModel(IContainerProvider containerProvider) : base(containerProvider)
{
_testReportService = containerProvider.Resolve<ITestReportService>();
_testCheckRecordService = containerProvider.Resolve<ITestCheckRecordService>();
LoadedCommand = new AsyncDelegateCommand(OnLoad);
QueryCommand = new AsyncDelegateCommand(OnQuery);
ExportCommand = new AsyncDelegateCommand(OnExport);
ExportReportCommand = new AsyncDelegateCommand(OnExportReportCommand);
}
#region
@@ -183,6 +187,39 @@ namespace ExportModule.ViewModels
await Task.Run(() => ExportToExcel(dialog.FileName, entities));
ShowInfoMessageBox($"导出完成,共 {entities.Count} 条步骤记录,已保存至:{dialog.FileName}", () => { });
}
/// <summary>
/// 导出测试报告:上层为各测试项(IsTestItem 子程序)的 PASS/NG 汇总,下层为每次 OKExpression 判断明细
/// </summary>
private async Task OnExportReportCommand()
{
if (SelectedTestReport == null)
{
ShowErrorMessageBox("请先在列表中选择一条测试记录。", () => { });
return;
}
StatusMessage = "正在查询测试项判断记录...";
var checkResult = await _testCheckRecordService.GetByTestRoundIdAsync(SelectedTestReport.TestRoundId);
if (!checkResult.IsSuccess || checkResult.Data == null || checkResult.Data.Count == 0)
{
ShowErrorMessageBox("未找到该测试记录的测试项判断数据(需将子程序标记为测试项后运行)。", () => { });
return;
}
var dialog = new SaveFileDialog
{
Filter = "Excel 工作簿 (*.xlsx)|*.xlsx|所有文件 (*.*)|*.*",
DefaultExt = ".xlsx",
FileName = $"测试报告_{SelectedTestReport.Scope}_{SelectedTestReport.StartTime:yyyyMMdd_HHmmss}.xlsx"
};
if (dialog.ShowDialog() != true) return;
var records = checkResult.Data;
StatusMessage = $"正在导出 {records.Count} 条测试项判断记录...";
await Task.Run(() => ExportReportToExcel(dialog.FileName, records, SelectedTestReport));
ShowInfoMessageBox($"导出完成,已保存至:{dialog.FileName}", () => { });
}
#endregion
@@ -239,6 +276,152 @@ namespace ExportModule.ViewModels
var dataRange = ws.Range(row, 1, row, headers.Length);
dataRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
dataRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
// 根据 Result 设置行背景色(与 UI DataGrid 配色一致)
// Result: "0"=运行中, "1"=通过, "2"=失败
if (e.Result is "运行中" or "成功" or "失败")
{
XLColor rowColor = e.Result switch
{
"运行中" => XLColor.FromArgb(0xB3, 0xD9, 0xFF), // 运行中 - 浅蓝
"成功" => XLColor.FromArgb(0xB2, 0xFF, 0xB2), // 通过 - 浅绿
"失败" => XLColor.FromArgb(0xFF, 0xB2, 0xB2), // 失败 - 浅红
_ => XLColor.White
};
dataRange.Style.Fill.BackgroundColor = rowColor;
}
// 异常流程步骤额外标记(字体加粗 + 橙色字体)
if (e.IsErrorStep)
dataRange.Style.Font.FontColor = XLColor.DarkOrange;
}
ws.Columns().AdjustToContents();
workbook.SaveAs(filePath);
}
/// <summary>
/// 测试报告导出:上层为各测试项(IsTestItem 子程序)的 PASS/NG 汇总,下层为每次 OKExpression 判断明细(含重复判断、判断时间、数据值)
/// </summary>
private static void ExportReportToExcel(string filePath, List<TestCheckRecordEntity> records, TestReportModel report)
{
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("测试报告");
const int colCount = 6;
var passColor = XLColor.FromArgb(0xB2, 0xFF, 0xB2); // 通过 - 浅绿(与现有导出配色一致)
var ngColor = XLColor.FromArgb(0xFF, 0xB2, 0xB2); // 失败 - 浅红
var headerColor = XLColor.FromArgb(0xEC, 0xEF, 0xF4);
int row = 1;
// ===== 标题与信息行 =====
ws.Range(row, 1, row, colCount).Merge();
ws.Cell(row, 1).Value = "测 试 报 告";
ws.Range(row, 1, row, colCount).Style.Font.Bold = true;
ws.Range(row, 1, row, colCount).Style.Font.FontSize = 16;
ws.Range(row, 1, row, colCount).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
row++;
ws.Cell(row, 1).Value = $"台架:{report.Scope ?? ""}";
ws.Cell(row, 3).Value = $"测试文件:{report.FileName ?? ""}";
row++;
ws.Cell(row, 1).Value = $"开始时间:{report.StartTime:yyyy-MM-dd HH:mm:ss}";
ws.Cell(row, 3).Value = $"结束时间:{report.EndTime:yyyy-MM-dd HH:mm:ss}";
row += 2;
// 按测试项分组(保持首次出现顺序)
var groups = records.GroupBy(r => r.TestItemName).ToList();
// ===== 上层:测试项汇总 =====
ws.Cell(row, 1).Value = "【测试项汇总】";
ws.Cell(row, 1).Style.Font.Bold = true;
row++;
string[] summaryHeaders = { "序号", "测试项", "判定结果", "执行次数", "判定次数", "NG次数" };
for (int c = 0; c < summaryHeaders.Length; c++)
ws.Cell(row, c + 1).Value = summaryHeaders[c];
var headerRange = ws.Range(row, 1, row, colCount);
headerRange.Style.Font.Bold = true;
headerRange.Style.Fill.BackgroundColor = headerColor;
headerRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
headerRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
row++;
int seq = 1;
foreach (var group in groups)
{
var summaries = group.Where(x => x.IsSummary).ToList();
var details = group.Where(x => !x.IsSummary).ToList();
bool overallPass = summaries.All(s => s.Pass) && details.All(d => d.Pass);
ws.Cell(row, 1).Value = seq++;
ws.Cell(row, 2).Value = group.Key;
ws.Cell(row, 3).Value = overallPass ? "PASS" : "NG";
ws.Cell(row, 4).Value = summaries.Count;
ws.Cell(row, 5).Value = details.Count;
ws.Cell(row, 6).Value = details.Count(d => !d.Pass);
var dataRange = ws.Range(row, 1, row, colCount);
dataRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
dataRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
ws.Cell(row, 3).Style.Font.Bold = true;
ws.Cell(row, 3).Style.Fill.BackgroundColor = overallPass ? passColor : ngColor;
row++;
}
row++;
// ===== 下层:判断明细 =====
ws.Cell(row, 1).Value = "【判断明细】";
ws.Cell(row, 1).Style.Font.Bold = true;
row++;
string[] detailHeaders = { "序号", "判定时间", "步骤名称", "OKExpression", "判定结果", "数据值" };
foreach (var group in groups)
{
var details = group.Where(x => !x.IsSummary).ToList();
bool overallPass = group.Where(x => x.IsSummary).All(s => s.Pass) && details.All(d => d.Pass);
// 测试项块标题行(合并单元格)
ws.Range(row, 1, row, colCount).Merge();
ws.Cell(row, 1).Value = $"■ 测试项:{group.Key}  总体结果:{(overallPass ? "PASS" : "NG")}";
ws.Cell(row, 1).Style.Font.Bold = true;
ws.Range(row, 1, row, colCount).Style.Fill.BackgroundColor = overallPass ? passColor : ngColor;
row++;
for (int c = 0; c < detailHeaders.Length; c++)
ws.Cell(row, c + 1).Value = detailHeaders[c];
var detailHeaderRange = ws.Range(row, 1, row, colCount);
detailHeaderRange.Style.Font.Bold = true;
detailHeaderRange.Style.Fill.BackgroundColor = headerColor;
detailHeaderRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
detailHeaderRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
row++;
if (details.Count == 0)
{
ws.Range(row, 1, row, colCount).Merge();
ws.Cell(row, 1).Value = "(无 OKExpression 判断,结果由步骤执行成败决定)";
row++;
}
int detailSeq = 1;
foreach (var d in details)
{
ws.Cell(row, 1).Value = detailSeq++;
ws.Cell(row, 2).Value = d.CreateTime.ToString("yyyy-MM-dd HH:mm:ss.fff");
ws.Cell(row, 3).Value = d.StepName ?? "";
ws.Cell(row, 4).Value = d.OKExpression ?? "";
ws.Cell(row, 5).Value = d.Pass ? "PASS" : "NG";
ws.Cell(row, 6).Value = d.Values ?? "";
var detailRange = ws.Range(row, 1, row, colCount);
detailRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
detailRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
ws.Cell(row, 5).Style.Font.Bold = true;
ws.Cell(row, 5).Style.Fill.BackgroundColor = d.Pass ? passColor : ngColor;
row++;
}
row++;
}
ws.Columns().AdjustToContents();
+9 -1
View File
@@ -60,6 +60,7 @@
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
@@ -109,13 +110,20 @@
Margin="4,0"
VerticalAlignment="Center"/>
<Button Grid.Column="7"
Content="导出 Excel"
Content="导出详细测试步骤"
Command="{Binding ExportCommand}"
Style="{StaticResource MaterialDesignFlatButton}"
Padding="16,6"
Margin="4,0"
VerticalAlignment="Center"/>
<Button Grid.Column="8"
Content="导出测试报告"
Command="{Binding ExportReportCommand}"
Style="{StaticResource MaterialDesignFlatButton}"
Padding="16,6"
Margin="4,0"
VerticalAlignment="Center"/>
<Button Grid.Column="9"
Content="加载全部"
Command="{Binding LoadedCommand}"
Style="{StaticResource MaterialDesignFlatButton}"
@@ -69,6 +69,10 @@ namespace MainModule.ViewModels
string json = System.IO.File.ReadAllText(filePath);
Newtonsoft.Json.JsonConvert.PopulateObject(json, _systemConfig);
}
else
{
_systemConfig = new SystemConfig();
}
}
//容器解析顺序不要改变!!!
_scopedContext = _scope.Resolve<ScopedContext>();
@@ -213,10 +217,10 @@ namespace MainModule.ViewModels
(LogAreaVM as IDisposable)?.Dispose();
(ParametersManagerVM as IDisposable)?.Dispose();
_globalInfo.ContextDic?.Remove(TestStatus);
_globalInfo.StepRunningDic?.Remove(TestStatus);
_globalInfo.ConfigDic?.Remove(TestStatus);
_globalInfo.ScopeDic?.Remove(TestStatus);
_globalInfo.ContextDic?.TryRemove(TestStatus, out _);
_globalInfo.StepRunningDic?.TryRemove(TestStatus, out _);
_globalInfo.ConfigDic?.TryRemove(TestStatus, out _);
_globalInfo.ScopeDic?.TryRemove(TestStatus, out _);
}
catch (Exception ex)
{
@@ -229,6 +233,12 @@ namespace MainModule.ViewModels
}
#region
private async Task OnLoad()
{
// 设备初始化期间仅对当前台架显示灰度遮罩层,初始化完成后关闭(finally 保证异常时也能关闭)
_eventAggregator.GetEvent<ScopeOverlayEvent>().Publish(new ScopeOverlayArgs { Scope = TestStatus, Show = true });
// 让遮罩层先完成渲染,再进入同步阻塞的设备初始化(泵送 Dispatcher 至 Render 优先级)
System.Windows.Application.Current?.Dispatcher.Invoke(() => { }, System.Windows.Threading.DispatcherPriority.Render);
try
{
if (!IsInitialized)
{
@@ -236,6 +246,12 @@ namespace MainModule.ViewModels
IsInitialized = true;
}
}
finally
{
_eventAggregator.GetEvent<ScopeOverlayEvent>().Publish(new ScopeOverlayArgs { Scope = TestStatus, Show = false });
}
}
private void OnRefresh()
{
@@ -259,10 +275,10 @@ namespace MainModule.ViewModels
if (navigationContext.Parameters.ContainsKey("Name"))
{
TestStatus = navigationContext.Parameters.GetValue<string>("Name");
_globalInfo.ContextDic.Add(TestStatus, _scopedContext);
_globalInfo.StepRunningDic.Add(TestStatus, _stepRunning);
_globalInfo.ScopeDic.Add(TestStatus, _scope);
_globalInfo.ConfigDic.Add(TestStatus, _systemConfig);
_globalInfo.ContextDic.TryAdd(TestStatus, _scopedContext);
_globalInfo.StepRunningDic.TryAdd(TestStatus, _stepRunning);
_globalInfo.ScopeDic.TryAdd(TestStatus, _scope);
_globalInfo.ConfigDic.TryAdd(TestStatus, _systemConfig);
if(_systemConfig.DefaultProgramFilePath != null&&File.Exists(_systemConfig.DefaultProgramFilePath))
{
var filePath = _systemConfig.DefaultProgramFilePath;
@@ -128,6 +128,26 @@
</vs:ParametersManager.Style>
</vs:ParametersManager>
<Border x:Name="Overlay"
Background="#40000000"
Visibility="Collapsed"
Panel.ZIndex="1"
Grid.RowSpan="2"
Grid.ColumnSpan="4">
<StackPanel Width="150"
VerticalAlignment="Center"
Margin="0 0 0 100">
<ProgressBar Width="80"
Height="80"
Margin="20"
IsIndeterminate="True"
Style="{StaticResource MaterialDesignCircularProgressBar}" />
<TextBlock FontSize="30"
Text="加载中......"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
</Grid>
</Border>
</UserControl>
+19 -2
View File
@@ -1,4 +1,6 @@
using System;
using Prism.Events;
using MainModule.ViewModels;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -12,6 +14,7 @@ using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using UIShare.PubEvent;
namespace MainModule.Views
{
@@ -20,9 +23,23 @@ namespace MainModule.Views
/// </summary>
public partial class AutomatedTestingView : UserControl
{
public AutomatedTestingView()
public AutomatedTestingView(IEventAggregator eventAggregator)
{
InitializeComponent();
eventAggregator.GetEvent<OverlayEvent>().Subscribe(ShowOverlay);
// 台架级加载遮罩:仅台架名称匹配时响应,避免其他台架/主窗口同时变灰
eventAggregator.GetEvent<ScopeOverlayEvent>().Subscribe(ShowScopeOverlay);
}
private void ShowOverlay(bool arg)
{
Overlay.Visibility = arg ? Visibility.Visible : Visibility.Collapsed;
}
private void ShowScopeOverlay(ScopeOverlayArgs args)
{
// 只处理属于当前台架的遮罩事件(TestStatus 在 OnNavigatedTo 时赋值)
if (DataContext is not AutomatedTestingViewModel vm || vm.TestStatus != args.Scope) return;
Overlay.Visibility = args.Show ? Visibility.Visible : Visibility.Collapsed;
}
}
}
+53
View File
@@ -0,0 +1,53 @@
using SqlSugar;
using System;
namespace Model.Entity
{
/// <summary>
/// 测试项判断记录:运行过程中测试项(IsTestItem 子程序)范围内每一次 OKExpression 判断。
/// 同一次运行的所有记录共享同一个 TestRoundId,导出测试报告时按此 Guid 查询。
/// IsSummary=true 的行为该测试项单次执行的汇总(PASS/NG),IsSummary=false 的行为单次判断明细。
/// </summary>
public class TestCheckRecordEntity : BaseEntity
{
/// <summary>同一次运行的统一标识(StepRunning.TestRoundID</summary>
[SugarColumn(ColumnName = "TestRoundId", ColumnDescription = "运行轮次标识")]
public Guid TestRoundId { get; set; }
/// <summary>作用域/台架名称</summary>
[SugarColumn(ColumnName = "Scope", ColumnDescription = "台架名称")]
public string Scope { get; set; } = string.Empty;
/// <summary>测试项名称(当前ADP文件路径)</summary>
[SugarColumn(ColumnName = "FileName", ColumnDescription = "测试项名称")]
public string FileName { get; set; } = string.Empty;
/// <summary>测试项名称(IsTestItem 子程序步骤的名称)</summary>
[SugarColumn(ColumnName = "TestItemName", Length = 200)]
public string TestItemName { get; set; } = string.Empty;
/// <summary>发生判断的步骤名称</summary>
[SugarColumn(ColumnName = "StepName", Length = 200)]
public string StepName { get; set; } = string.Empty;
/// <summary>子程序嵌套深度(0=主程序)</summary>
[SugarColumn(ColumnName = "Depth")]
public int Depth { get; set; }
/// <summary>判断表达式</summary>
[SugarColumn(ColumnName = "OKExpression", Length = 1000, IsNullable = true)]
public string? OKExpression { get; set; }
/// <summary>判断结果(true=PASS / false=NG</summary>
[SugarColumn(ColumnName = "Pass")]
public bool Pass { get; set; }
/// <summary>表达式变量的实际取值(如 "电压=12.5; 电流=3.2; "</summary>
[SugarColumn(ColumnName = "Values", Length = 2000, IsNullable = true)]
public string? Values { get; set; }
/// <summary>是否为汇总行(每个测试项单次执行结束时写入一条)</summary>
[SugarColumn(ColumnName = "IsSummary")]
public bool IsSummary { get; set; }
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="SqlSugarCore" Version="5.1.4.215-preview13" />
<PackageReference Include="System.IO.Ports" Version="11.0.0-preview.4.26230.115" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
</Project>
+3
View File
@@ -12,6 +12,9 @@ namespace Model.Models
public bool IsUsed { get; set; } = true;
/// <summary>是否测试项(仅子程序步骤可标记,运行时记录其范围内所有 OKExpression 判断)</summary>
public bool IsTestItem { get; set; } = false;
public int Index { get; set; }
public string? Name { get; set; }
+3 -1
View File
@@ -433,7 +433,9 @@ namespace MonitorModule.ViewModels
.Where(m =>
{
if (m.GetCustomAttribute<MonitorableAttribute>() == null) return false;
if (m.ReturnType != typeof(Task<string>)) return false;
// 同时支持 Task<string> 和 Task<double> 返回类型
var rt = m.ReturnType;
if (rt != typeof(Task<string>) && rt != typeof(Task<double>)) return false;
var parms = m.GetParameters();
return parms.Length == 0 ||
(parms.Length == 1 && parms[0].ParameterType == typeof(CancellationToken));
+3 -1
View File
@@ -233,7 +233,9 @@ namespace MonitorModule.ViewModels
.Where(m =>
{
if (m.GetCustomAttribute<MonitorableAttribute>() == null) return false;
if (m.ReturnType != typeof(Task<string>)) return false;
// 同时支持 Task<string> 和 Task<double> 返回类型
var rt = m.ReturnType;
if (rt != typeof(Task<string>) && rt != typeof(Task<double>)) return false;
var parms = m.GetParameters();
return parms.Length == 0 ||
(parms.Length == 1 && parms[0].ParameterType == typeof(CancellationToken));
+39
View File
@@ -128,5 +128,44 @@ namespace ORM
throw new Exception("连接数据库失败");
}
}
/// <summary>
/// 归档过期数据库文件:将旧的 SQLite 文件重命名为 "SQL_归档_yyyyMMdd_HHmmss.db"
/// 后续 <see cref="CreateDatabaseAndCheckConnection"/> 会自动创建新的空数据库。
/// <para>
/// 必须在 <see cref="InitSqlite"/> 之后、<see cref="CreateDatabaseAndCheckConnection"/> 之前调用,
/// 此时连接字符串已就绪但数据库文件尚未被打开。
/// </para>
/// </summary>
/// <param name="retentionDays">数据库文件保留天数,默认 180 天(半年)</param>
public static void TryArchiveOldDatabase(int retentionDays = 180)
{
try
{
// 从连接字符串中提取文件路径(格式:Data Source=xxx;
string dbPath = DbConnectionString
.Replace("Data Source=", "", StringComparison.OrdinalIgnoreCase)
.TrimEnd(';');
if (!File.Exists(dbPath)) return;
var cutoff = DateTime.Now.AddDays(-retentionDays);
var fileInfo = new FileInfo(dbPath);
if (fileInfo.LastWriteTime < cutoff)
{
string archiveName = $"SQL_归档_{fileInfo.LastWriteTime:yyyyMMdd_HHmmss}.db";
string archivePath = Path.Combine(fileInfo.DirectoryName!, archiveName);
File.Move(dbPath, archivePath);
System.Diagnostics.Debug.WriteLine(
$"[数据库归档] {dbPath} → {archivePath}(文件最后修改于 {fileInfo.LastWriteTime:yyyy-MM-dd},已超过 {retentionDays} 天)");
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"数据库归档失败(不影响软件正常使用):{ex.Message}");
}
}
}
}
@@ -0,0 +1,38 @@
using Model;
using Model.Entity;
using ORM;
using Service.Interface;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Service.Implement
{
public class TestCheckRecordService : BaseService<TestCheckRecordEntity>, ITestCheckRecordService
{
public TestCheckRecordService(SqlSugarRepository<TestCheckRecordEntity> repository) : base(repository)
{
}
/// <summary>
/// 根据 TestRoundId 查询该次运行的所有测试项判断记录(按创建时间升序)
/// </summary>
public async Task<Result<List<TestCheckRecordEntity>>> GetByTestRoundIdAsync(Guid testRoundId)
{
try
{
var list = await _repository.Entities
.Where(x => x.TestRoundId == testRoundId)
.OrderBy(x => x.CreateTime)
.ToListAsync();
return Result<List<TestCheckRecordEntity>>.Success(list);
}
catch (Exception ex)
{
return Result<List<TestCheckRecordEntity>>.Error("根据 TestRoundId 查询测试项判断记录失败", ex);
}
}
}
}
@@ -0,0 +1,19 @@
using Model;
using Model.Entity;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Service.Interface
{
/// <summary>
/// 测试项判断记录服务:运行时记录测试项范围内每一次 OKExpression 判断,导出测试报告时查询。
/// </summary>
public interface ITestCheckRecordService : IBaseService<TestCheckRecordEntity>
{
/// <summary>
/// 根据 TestRoundId 查询该次运行的所有测试项判断记录(按创建时间升序)
/// </summary>
Task<Result<List<TestCheckRecordEntity>>> GetByTestRoundIdAsync(Guid testRoundId);
}
}
+131 -16
View File
@@ -192,21 +192,26 @@ namespace TestingModule.ViewModels
var assembly = Assembly.LoadFrom(dllPath);
Assemblies.Add(assembly);
// 加载对应的XML注释文件 (项目没有用到)
//string xmlPath = Path.ChangeExtension(dllPath, ".xml");
//if (File.Exists(xmlPath))
//{
// try
// {
// XmlDocument xmlDoc = new XmlDocument();
// xmlDoc.Load(xmlPath);
// _xmlDocumentCache[assembly.FullName!] = xmlDoc;
// }
// catch (Exception xmlEx)
// {
// LoggerHelper.WarnWithNotify($"加载XML注释失败: {Path.GetFileName(xmlPath)} - {xmlEx.Message}");
// }
//}
//加载对应的XML注释文件
string xmlPath = Path.ChangeExtension(dllPath, ".xml");
if (File.Exists(xmlPath))
{
try
{
XmlDocument xmlDoc = new XmlDocument();
xmlDoc.Load(xmlPath);
XmlDocumentCache[assembly.FullName!] = xmlDoc;
LoggerHelper.Info($"[XML注释] 成功加载: {Path.GetFileName(xmlPath)} -> 程序集 [{assembly.FullName}]");
}
catch (Exception xmlEx)
{
LoggerHelper.Warn($"加载XML注释失败: {Path.GetFileName(xmlPath)} - {xmlEx.Message}");
}
}
else
{
LoggerHelper.Warn($"[XML注释] XML文件不存在: {xmlPath}");
}
}
catch (Exception ex)
{
@@ -284,6 +289,7 @@ namespace TestingModule.ViewModels
{
Name = subProgram.Name,
Tag = subProgram,
Tooltip = subProgram.FilePath
});
}
@@ -368,6 +374,7 @@ namespace TestingModule.ViewModels
{
Name = $"{method.Name}({paramText})",
Tag = method,
Tooltip = GetMethodDocumentation(method),
};
typeNode.Children.Add(methodNode);
@@ -433,6 +440,95 @@ namespace TestingModule.ViewModels
}
}
}
// 添加获取注释的方法
private string? GetMethodDocumentation(MethodInfo method)
{
if (method.DeclaringType == null) return null;
try
{
string assemblyName = method.DeclaringType.Assembly.FullName!;
if (!_xmlDocumentCache.TryGetValue(assemblyName, out XmlDocument? xmlDoc))
{
LoggerHelper.Warn($"[XML注释] 缓存未命中: 程序集 [{assemblyName}],缓存包含 { _xmlDocumentCache.Count} 个条目: [{string.Join(", ", _xmlDocumentCache.Keys)}]");
return null;
}
// 生成XML文档中的成员ID
string memberName = $"M:{method.DeclaringType.FullName}.{method.Name}";
var parameters = method.GetParameters();
if (parameters.Length > 0)
{
memberName += "(" + string.Join(",", parameters.Select(p => p.ParameterType.FullName)) + ")";
}
// 查找注释节点
XmlNode? memberNode = xmlDoc.SelectSingleNode($"//member[@name='{memberName}']");
if (memberNode == null)
{
LoggerHelper.Warn($"[XML注释] 节点未找到: {memberName}");
return null;
}
// 获取摘要(summary
var summaryNode = memberNode.SelectSingleNode("summary");
string documentation = "";
if (summaryNode != null)
{
documentation += CleanXmlContent(summaryNode.InnerXml);
}
// 获取参数注释(param
var paramNodes = memberNode.SelectNodes("param");
if (paramNodes != null && paramNodes.Count > 0)
{
documentation += "\n\n参数:";
foreach (XmlNode paramNode in paramNodes)
{
string? paramName = paramNode.Attributes?["name"]?.Value;
if (!string.IsNullOrEmpty(paramName))
{
documentation += $"\n • {paramName}: {CleanXmlContent(paramNode.InnerXml)}";
}
}
}
// 获取返回值注释(returns
var returnsNode = memberNode.SelectSingleNode("returns");
if (returnsNode != null)
{
documentation += $"\n\n返回值: {CleanXmlContent(returnsNode.InnerXml)}";
}
return string.IsNullOrWhiteSpace(documentation)
? null
: System.Net.WebUtility.HtmlDecode(documentation.Trim());
}
catch (Exception ex)
{
LoggerHelper.Warn($"获取注释失败: {method.Name} - {ex.Message}");
return null;
}
}
// 辅助方法:清理XML内容
private string CleanXmlContent(string xmlContent)
{
return xmlContent
.Replace("<see cref=\"", "")
.Replace("\"/>", "")
.Replace("<para>", "\n")
.Replace("</para>", "")
.Replace("<seealso", "")
.Replace("/>", "")
.Replace("<c>", "") // 处理代码标签
.Replace("</c>", "")
.Replace("<code>", "")
.Replace("</code>", "")
.Trim();
}
#endregion
#region
@@ -515,7 +611,7 @@ namespace TestingModule.ViewModels
var newStep = new StepVM
{
Name = subProgram.Name,
StepType = "子程序"
StepType = "子程序",
};
var jsonstr = File.ReadAllText($"{subProgram.FilePath}");
var tmp = JsonConvert.DeserializeObject<ProgramVM>(jsonstr);
@@ -568,6 +664,8 @@ namespace TestingModule.ViewModels
{
// 查找最近的未匹配循环开始
StepVM? lastUnmatchedLoopStart = null;
if (_ScopedContext.SelectedStepList == "主程序")
{
for (int i = Program.StepCollection.Count - 1; i >= 0; i--)
{
if (Program.StepCollection[i].StepType == "循环开始")
@@ -581,6 +679,23 @@ namespace TestingModule.ViewModels
}
}
}
}
else
{
for (int i = Program.ErrorStepCollection.Count - 1; i >= 0; i--)
{
if (Program.ErrorStepCollection[i].StepType == "循环开始")
{
bool isMatched = Program.ErrorStepCollection.Any(s => s.StepType == "循环结束" && s.LoopStartStepId == Program.StepCollection[i].ID);
if (!isMatched)
{
lastUnmatchedLoopStart = Program.ErrorStepCollection[i];
break;
}
}
}
}
var newStep = new StepVM
{
+187 -22
View File
@@ -94,6 +94,30 @@ namespace TestingModule.ViewModels
public ICommand DeleteStepCommand { get;set; }
public ICommand TabSelectionChangedCommand { get;set; }
public ICommand SelectionChangedCommand { get;set; }
public ICommand OpenSubProgramCommand { get; set; }
public ICommand GoBackCommand { get; set; }
public ICommand ToggleTestItemCommand { get; set; }
#endregion
#region /
// --- 主程序 Tab 导航属性 ---
public ProgramVM MainCurrentProgram => _ScopedContext.MainNav.CurrentProgram;
public string MainBreadcrumbPath => _ScopedContext.MainNav.BreadcrumbPath;
public bool MainCanGoBack => _ScopedContext.MainNav.CanGoBack;
public ObservableCollection<StepVM> MainDisplaySteps => _ScopedContext.MainNav.DisplaySteps;
// --- 错误程序 Tab 导航属性 ---
public ProgramVM ErrorCurrentProgram => _ScopedContext.ErrorNav.CurrentProgram;
public string ErrorBreadcrumbPath => _ScopedContext.ErrorNav.BreadcrumbPath;
public bool ErrorCanGoBack => _ScopedContext.ErrorNav.CanGoBack;
public ObservableCollection<StepVM> ErrorDisplaySteps => _ScopedContext.ErrorNav.DisplaySteps;
/// <summary>
/// 当前激活的导航状态(根据选中的 Tab 索引决定)
/// </summary>
private ProgramNavigationState ActiveNav => SelectedTabIndex == 1 ? _ScopedContext.ErrorNav : _ScopedContext.MainNav;
#endregion
public StepsManagerViewModel(IContainerProvider containerProvider, ScopedContext scopedContext, SystemConfig systemConfig, GlobalInfo globalInfo) : base(containerProvider)
@@ -108,17 +132,62 @@ namespace TestingModule.ViewModels
DeleteStepCommand = new DelegateCommand(DeleteStep);
TabSelectionChangedCommand = new DelegateCommand<string>(TabSelectionChanged);
SelectionChangedCommand = new DelegateCommand<object>(SelectionChanged);
OpenSubProgramCommand = new DelegateCommand(OpenSubProgram);
GoBackCommand = new DelegateCommand(GoBack);
ToggleTestItemCommand = new DelegateCommand(ToggleTestItem);
SubscribeStepCollections();
Program.PropertyChanged += Program_PropertyChanged;
Admin = _globalInfo.IsAdmin;
// 初始化两套导航状态
_ScopedContext.MainNav.Initialize(Program, "主程序");
_ScopedContext.ErrorNav.Initialize(Program, "错误程序");
// 订阅主程序导航状态变化 → 转发给 UI
_ScopedContext.MainNav.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(ProgramNavigationState.CurrentProgram))
{
RaisePropertyChanged(nameof(MainCurrentProgram));
RaisePropertyChanged(nameof(MainDisplaySteps));
}
else if (e.PropertyName == nameof(ProgramNavigationState.BreadcrumbPath))
RaisePropertyChanged(nameof(MainBreadcrumbPath));
else if (e.PropertyName == nameof(ProgramNavigationState.CanGoBack))
RaisePropertyChanged(nameof(MainCanGoBack));
};
// 订阅错误程序导航状态变化 → 转发给 UI
_ScopedContext.ErrorNav.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(ProgramNavigationState.CurrentProgram))
{
RaisePropertyChanged(nameof(ErrorCurrentProgram));
RaisePropertyChanged(nameof(ErrorDisplaySteps));
}
else if (e.PropertyName == nameof(ProgramNavigationState.BreadcrumbPath))
RaisePropertyChanged(nameof(ErrorBreadcrumbPath));
else if (e.PropertyName == nameof(ProgramNavigationState.CanGoBack))
RaisePropertyChanged(nameof(ErrorCanGoBack));
};
// 订阅子程序导航事件(运行时由 StepRunning 发布)
_eventAggregator.GetEvent<SubProgramNavigateEvent>()
.Subscribe(OnSubProgramNavigate, ThreadOption.UIThread, false,
payload => payload.Scope == _systemConfig.Title);
}
private void SelectionChanged(object parameter)
{
var selectedList = parameter as IList;
if (selectedList != null)
if (parameter is IList list && list.Count > 0)
{
SelectedItems = selectedList.Cast<StepVM>().ToList();
SelectedItems = list.Cast<StepVM>().ToList();
}
else if (parameter is IEnumerable enumerable && parameter is not string)
{
var items = enumerable.Cast<StepVM>().ToList();
if (items.Count > 0)
SelectedItems = items;
}
}
@@ -137,15 +206,21 @@ namespace TestingModule.ViewModels
}
private void CopyStep()
{
if (_globalInfo.IsAdmin && SelectedItems.Any())
{
if (!_globalInfo.IsAdmin) return;
// 优先用多选列表,回退到单选
var source = (SelectedItems != null && SelectedItems.Any())
? SelectedItems
: (SelectedStep != null ? new List<StepVM> { SelectedStep } : null);
if (source == null || !source.Any()) return;
tmpCopyList.Clear();
foreach (var item in SelectedItems)
foreach (var item in source)
{
tmpCopyList.Add(item);
}
}
}
private void PasteStep()
{
// 权限校验并确保有可粘贴的内容
@@ -180,32 +255,116 @@ namespace TestingModule.ViewModels
}
private void DeleteStep()
{
// 确保有选中的项
if (_globalInfo.IsAdmin && SelectedItems != null && SelectedItems.Any())
{
// 创建一个副本进行循环,防止在 Remove 过程中集合变化导致的问题
var toDelete = SelectedItems.ToList();
if (!_globalInfo.IsAdmin) return;
foreach (var item in toDelete)
// 优先用多选列表,回退到单选
var source = (SelectedItems != null && SelectedItems.Any())
? SelectedItems.ToList()
: (SelectedStep != null ? new List<StepVM> { SelectedStep } : null);
if (source == null || !source.Any()) return;
foreach (var item in source)
{
_eventAggregator.GetEvent<DeletedStepEvent>().Publish(item.ID);
if (_ScopedContext.SelectedStepList == "主程序")
{
Program.StepCollection.Remove(item);
}
else if (_ScopedContext.SelectedStepList == "错误程序")
{
Program.ErrorStepCollection.Remove(item);
}
// 清空 ViewModel 的选中状态,避免悬挂引用
SelectedStep = null;
SelectedItems?.Clear();
_ScopedContext.SelectedStep = null;
}
#endregion
#region
/// <summary>
/// 编辑模式:右键打开子程序(操作当前激活的 Tab 的导航状态)
/// </summary>
private void OpenSubProgram()
{
if (SelectedStep == null || SelectedStep.StepType != "子程序" || SelectedStep.SubProgram == null)
return;
var nav = ActiveNav;
nav.NavigationStack.Push(nav.CurrentProgram);
nav.UpdateCanGoBack();
var stepName = SelectedStep.Name ?? "子程序";
nav.BreadcrumbPath = $"{nav.BreadcrumbPath} > {stepName}";
nav.CurrentProgram = SelectedStep.SubProgram;
}
/// <summary>
/// 切换选中步骤的测试项标记(仅子程序可标记,运行时将记录其范围内所有 OKExpression 判断)
/// </summary>
private void ToggleTestItem()
{
if (!_globalInfo.IsAdmin) return;
var source = (SelectedItems != null && SelectedItems.Any())
? SelectedItems
: (SelectedStep != null ? new List<StepVM> { SelectedStep } : null);
if (source == null || !source.Any()) return;
foreach (var item in source.Where(x => x.StepType == "子程序" && x.SubProgram != null))
{
item.IsTestItem = !item.IsTestItem;
}
}
// 3. 清空 ViewModel 的选中状态,避免悬挂引用
SelectedStep = null;
SelectedItems.Clear();
_ScopedContext.SelectedStep = null;
/// <summary>
/// 返回上一级程序(操作当前激活的 Tab 的导航状态)
/// </summary>
private void GoBack()
{
var nav = ActiveNav;
if (nav.NavigationStack.Count == 0)
return;
var parentProgram = nav.NavigationStack.Pop();
nav.UpdateCanGoBack();
nav.CurrentProgram = parentProgram;
var parts = nav.BreadcrumbPath.Split(" > ");
nav.BreadcrumbPath = parts.Length > 1
? string.Join(" > ", parts.Take(parts.Length - 1))
: nav == _ScopedContext.MainNav ? "主程序" : "错误程序";
}
/// <summary>
/// 运行时:处理 StepRunning 发布的子程序导航事件
/// </summary>
private void OnSubProgramNavigate(SubProgramNavigatePayload payload)
{
if (_ScopedContext == null || payload == null) return;
var nav = payload.IsErrorProgram ? _ScopedContext.ErrorNav : _ScopedContext.MainNav;
if (payload.Action == NavigateAction.Enter && payload.SubProgram != null)
{
nav.NavigationStack.Push(nav.CurrentProgram);
nav.UpdateCanGoBack();
nav.CurrentProgram = payload.SubProgram;
var stepName = payload.StepName ?? "子程序";
nav.BreadcrumbPath = $"{nav.BreadcrumbPath} > {stepName}";
}
else if (payload.Action == NavigateAction.Exit)
{
if (nav.NavigationStack.Count == 0) return;
var parentProgram = nav.NavigationStack.Pop();
nav.UpdateCanGoBack();
nav.CurrentProgram = parentProgram;
var parts = nav.BreadcrumbPath.Split(" > ");
nav.BreadcrumbPath = parts.Length > 1
? string.Join(" > ", parts.Take(parts.Length - 1))
: nav == _ScopedContext.MainNav ? "主程序" : "错误程序";
}
}
#endregion
#region
@@ -252,6 +411,12 @@ namespace TestingModule.ViewModels
UnsubscribeStepCollections();
SubscribeStepCollections();
// 通知 UI 刷新 DisplaySteps(导航状态虽然引用同一 Program,但集合实例已变)
RaisePropertyChanged(nameof(MainDisplaySteps));
RaisePropertyChanged(nameof(ErrorDisplaySteps));
RaisePropertyChanged(nameof(MainCurrentProgram));
RaisePropertyChanged(nameof(ErrorCurrentProgram));
// 集合被整体替换后,对新集合重新编号
Application.Current?.Dispatcher.BeginInvoke(new Action(() =>
{
@@ -304,9 +469,9 @@ namespace TestingModule.ViewModels
Program.PropertyChanged -= Program_PropertyChanged;
}
// 3. 【核心修复】必须显式退订 Prism 全局事件(AlarmEvent
// 注意:因为订阅时使用的是匿名 Lambda,最安全稳妥的退订方式是把整个事件上的当前 VM 订阅者全部注销
// 3. 显式退订全局 Prism 事件
_eventAggregator?.GetEvent<AlarmEvent>()?.Unsubscribe(null);
_eventAggregator?.GetEvent<SubProgramNavigateEvent>()?.Unsubscribe(null);
// 4. 清空临时缓存集合与 UI 绑定列表,避免悬挂指针
tmpCopyList?.Clear();
+2 -1
View File
@@ -53,7 +53,8 @@
<TreeView.Resources>
<HierarchicalDataTemplate DataType="{x:Type model:InstructionNodeVM}"
ItemsSource="{Binding Children}">
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Name}" ToolTip="{Binding Tooltip}"/>
</HierarchicalDataTemplate>
</TreeView.Resources>
<!-- 双击 -->
+2 -2
View File
@@ -103,7 +103,7 @@
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="50" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
@@ -240,7 +240,7 @@
<DataTemplate>
<Grid Visibility="{Binding Category, Converter={StaticResource ParameterCategoryToVisibilityConverter}, ConverterParameter=Item}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="50" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
+56 -2
View File
@@ -8,9 +8,13 @@
xmlns:behaviors="clr-namespace:UIShare.Behaviors;assembly=UIShare"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:prism="http://prismlibrary.com/"
xmlns:converters="clr-namespace:UIShare.Converters;assembly=UIShare"
d:DesignHeight="450"
d:DesignWidth="800"
mc:Ignorable="d">
<UserControl.Resources>
<converters:BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
</UserControl.Resources>
<Grid>
<GroupBox Header="{Binding Title}">
<TabControl SelectedIndex="{Binding SelectedTabIndex, Mode=TwoWay}">
@@ -19,6 +23,17 @@
CommandParameter="{Binding SelectedTabHeader}" />
</i:Interaction.Behaviors>
<TabItem Header="主程序">
<DockPanel>
<!-- 主程序面包屑导航栏 -->
<Border DockPanel.Dock="Top" Background="#F0F0F0" Padding="6,3" Visibility="{Binding MainCanGoBack, Converter={StaticResource BooleanToVisibilityConverter}}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="↩ 返回上一级"
Command="{Binding GoBackCommand}"
Style="{StaticResource MaterialDesignFlatButton}"
FontSize="12" Padding="8,2" Margin="8,0,0,0"/>
<TextBlock Text="{Binding MainBreadcrumbPath}" VerticalAlignment="Center" FontSize="12" Foreground="#555"/>
</DockPanel>
</Border>
<DataGrid dd:DragDrop.IsDragSource="{Binding Admin}"
dd:DragDrop.IsDropTarget="{Binding Admin}"
dd:DragDrop.UseDefaultDragAdorner="{Binding Admin}"
@@ -29,7 +44,7 @@
Background="Transparent"
CanUserAddRows="False"
CanUserSortColumns="False"
ItemsSource="{Binding Program.StepCollection}"
ItemsSource="{Binding MainDisplaySteps}"
SelectedItem="{Binding SelectedStep, Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
SelectionMode="Extended"
SelectionUnit="FullRow">
@@ -38,6 +53,10 @@
<i:InvokeCommandAction Command="{Binding SelectionChangedCommand}"
CommandParameter="{Binding SelectedItems, RelativeSource={RelativeSource AncestorType=DataGrid}}" />
</i:EventTrigger>
<i:EventTrigger EventName="ContextMenuOpening">
<i:InvokeCommandAction Command="{Binding SelectionChangedCommand}"
CommandParameter="{Binding SelectedItems, RelativeSource={RelativeSource AncestorType=DataGrid}}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<!-- 行样式 -->
<DataGrid.RowStyle>
@@ -80,6 +99,10 @@
<DataGridCheckBoxColumn Width="58"
Binding="{Binding IsUsed, UpdateSourceTrigger=PropertyChanged}"
Header="启用" />
<DataGridCheckBoxColumn Width="58"
Binding="{Binding IsTestItem}"
Header="测试项"
IsReadOnly="True" />
<DataGridTextColumn Binding="{Binding Index}"
Header="序号"
IsReadOnly="True" />
@@ -120,14 +143,31 @@
<MenuItem Header="删除"
Foreground="Red"
Command="{Binding DeleteStepCommand}" />
<Separator/>
<MenuItem Header="打开子程序"
Command="{Binding OpenSubProgramCommand}" />
<MenuItem Header="标记/取消测试项"
Command="{Binding ToggleTestItemCommand}" />
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid>
</DockPanel>
</TabItem>
<TabItem Header="错误程序">
<DockPanel>
<!-- 错误程序面包屑导航栏 -->
<Border DockPanel.Dock="Top" Background="#F0F0F0" Padding="6,3" Visibility="{Binding ErrorCanGoBack, Converter={StaticResource BooleanToVisibilityConverter}}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="↩ 返回上一级"
Command="{Binding GoBackCommand}"
Style="{StaticResource MaterialDesignFlatButton}"
FontSize="12" Padding="8,2" Margin="8,0,0,0"/>
<TextBlock Text="{Binding ErrorBreadcrumbPath}" VerticalAlignment="Center" FontSize="12" Foreground="#555"/>
</DockPanel>
</Border>
<DataGrid dd:DragDrop.IsDragSource="True"
dd:DragDrop.IsDropTarget="True"
dd:DragDrop.UseDefaultDragAdorner="True"
@@ -135,7 +175,7 @@
Background="Transparent"
CanUserAddRows="False"
CanUserSortColumns="False"
ItemsSource="{Binding Program.ErrorStepCollection}"
ItemsSource="{Binding ErrorDisplaySteps}"
SelectedItem="{Binding SelectedStep, Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
SelectionMode="Extended"
SelectionUnit="FullRow">
@@ -144,6 +184,10 @@
<i:InvokeCommandAction Command="{Binding SelectionChangedCommand}"
CommandParameter="{Binding SelectedItems, RelativeSource={RelativeSource AncestorType=DataGrid}}" />
</i:EventTrigger>
<i:EventTrigger EventName="ContextMenuOpening">
<i:InvokeCommandAction Command="{Binding SelectionChangedCommand}"
CommandParameter="{Binding SelectedItems, RelativeSource={RelativeSource AncestorType=DataGrid}}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<!-- 行样式 -->
<DataGrid.RowStyle>
@@ -186,6 +230,10 @@
<DataGridCheckBoxColumn Width="58"
Binding="{Binding IsUsed, UpdateSourceTrigger=PropertyChanged}"
Header="启用" />
<DataGridCheckBoxColumn Width="58"
Binding="{Binding IsTestItem}"
Header="测试项"
IsReadOnly="True" />
<DataGridTextColumn Binding="{Binding Index}"
Header="序号"
IsReadOnly="True" />
@@ -226,6 +274,11 @@
<MenuItem Header="删除"
Foreground="Red"
Command="{Binding DeleteStepCommand}" />
<Separator/>
<MenuItem Header="打开子程序"
Command="{Binding OpenSubProgramCommand}" />
<MenuItem Header="标记/取消测试项"
Command="{Binding ToggleTestItemCommand}" />
</ContextMenu>
</DataGrid.ContextMenu>
@@ -239,6 +292,7 @@
</i:Interaction.Triggers>-->
</DataGrid>
</DockPanel>
</TabItem>
</TabControl>
</GroupBox>
-45
View File
@@ -29,52 +29,7 @@ namespace UIShare.GlobalVariable
}
return true;
}
/// <summary>
/// 根据标题(格子标识)加载独立的配置文件
/// </summary>
public static SystemConfig Load(string title)
{
if (string.IsNullOrEmpty(title))
{
throw new ArgumentException("配置标题不能为空", nameof(title));
}
// 临时实例化一个对象以获取默认的 SystemPath
var dummy = new SystemConfig();
string configPath = Path.Combine(dummy.SystemPath, $"{title}.json");
if (!File.Exists(configPath))
{
// 如果不存在,创建一个带 Title 的默认配置并保存
var defaultConfig = new SystemConfig { Title = title };
EnsureDefaultCanDevice(defaultConfig);
Save(defaultConfig);
return defaultConfig;
}
lock (_fileLock)
{
try
{
string json = File.ReadAllText(configPath);
var config = JsonConvert.DeserializeObject<SystemConfig>(json, new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All
});
config ??= new SystemConfig { Title = title };
EnsureDefaultCanDevice(config);
return config;
}
catch (Exception ex)
{
LoggerHelper.ErrorWithNotify(title, $"格子 [{title}] 配置加载失败: {ex.Message}");
var fallback = new SystemConfig { Title = title };
EnsureDefaultCanDevice(fallback);
return fallback;
}
}
}
/// <summary>
/// 保存指定的配置实例
@@ -0,0 +1,255 @@
using DeviceCommand.Base;
using Logger;
using Model.Models;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace UIShare.GlobalVariable
{
/// <summary>
/// 设备健康监控器:独立于监控采样的心跳重连机制。
/// <para>
/// 设计原则——与检测值零冲突:
/// <list type="bullet">
/// <item>健康检查平时只读 <see cref="IBaseInterface.IsConnected"/>(纯本地属性,零网络开销,不碰通信锁)</item>
/// <item>仅在 IsConnected==false 时才获取 _commLock 执行重连,与监控采样天然串行化</item>
/// <item>ModbusTcp 的 ConnectAsync 是幂等的(已连接时直接返回),不会中断正在进行的监控</item>
/// <item>TCP 的 ConnectAsync 会重置连接,但监控的 catch 容忍单次失败,下次 tick 自动恢复</item>
/// </list>
/// </para>
/// </summary>
public class DeviceHealthMonitor : IDisposable
{
private readonly IDictionary<string, IBaseInterface> _deviceMap;
private readonly SystemConfig _systemConfig;
private readonly string _scopeName;
/// <summary>健康检查定时器</summary>
private Timer? _healthCheckTimer;
/// <summary>每个设备的连续失败计数</summary>
private readonly ConcurrentDictionary<string, int> _failureCounts = new();
/// <summary>每个设备的重连尝试次数(用于计算退避阈值)</summary>
private readonly ConcurrentDictionary<string, int> _reconnectAttempts = new();
/// <summary>基础失败阈值(首次重连触发值)</summary>
private const int BaseFailureThreshold = 3;
/// <summary>退避上限(最大阈值)</summary>
private const int MaxBackoffThreshold = 15;
/// <summary>健康检查间隔(毫秒)</summary>
private readonly int _checkIntervalMs;
private bool _disposed;
private readonly object _startStopLock = new();
/// <summary>
/// 创建健康监控器实例。
/// </summary>
/// <param name="deviceMap">当前作用域的设备字典</param>
/// <param name="systemConfig">当前作用域的系统配置(用于更新 DeviceInfoVM.IsConnected</param>
/// <param name="scopeName">作用域名称(日志标识)</param>
/// <param name="checkIntervalMs">健康检查间隔,默认 5000ms</param>
public DeviceHealthMonitor(
IDictionary<string, IBaseInterface> deviceMap,
SystemConfig systemConfig,
string scopeName,
int checkIntervalMs = 5000)
{
_deviceMap = deviceMap;
_systemConfig = systemConfig;
_scopeName = scopeName;
_checkIntervalMs = checkIntervalMs;
}
/// <summary>启动健康监控(幂等:多次调用只启动一次)</summary>
public void Start()
{
lock (_startStopLock)
{
if (_disposed || _healthCheckTimer != null) return;
_healthCheckTimer = new Timer(
OnHealthCheckTick,
null,
TimeSpan.FromSeconds(10), // 首次检查延迟 10 秒,避免启动时设备尚未连接完成
TimeSpan.FromMilliseconds(_checkIntervalMs));
LoggerHelper.Info($"[{_scopeName}] 设备健康监控已启动,检查间隔={_checkIntervalMs}ms,基础重连阈值={BaseFailureThreshold}次(指数退避上限={MaxBackoffThreshold}");
}
}
/// <summary>停止健康监控</summary>
public void Stop()
{
lock (_startStopLock)
{
_healthCheckTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_healthCheckTimer?.Dispose();
_healthCheckTimer = null;
_failureCounts.Clear();
_reconnectAttempts.Clear();
}
}
/// <summary>
/// 健康检查核心逻辑:遍历设备,检查连接状态,失败计数超阈值则重连。
/// <para>TCP 设备额外进行主动探活(*IDN?),以检测死连接(对端崩溃但 TCP 未收到 FIN)。</para>
/// </summary>
private async void OnHealthCheckTick(object? state)
{
if (_disposed || _deviceMap.Count == 0) return;
// 快照避免枚举期间字典被修改
var snapshot = _deviceMap.ToArray();
foreach (var kvp in snapshot)
{
if (_disposed) return;
string deviceName = kvp.Key;
var device = kvp.Value;
try
{
bool alive = device.IsConnected;
// TCP 设备主动探活:IsConnected 只反映上次操作状态,无法检测死连接
if (alive && device is Tcp tcpDevice)
{
alive = await ProbeTcpDeviceAsync(tcpDevice);
}
if (alive)
{
// 连接正常:清零失败计数与退避
_failureCounts.TryRemove(deviceName, out _);
_reconnectAttempts.TryRemove(deviceName, out _);
continue;
}
// 连接断开:累加失败计数
int failures = _failureCounts.AddOrUpdate(deviceName, 1, (_, count) => count + 1);
// 计算当前退避阈值:基础值 + 重连尝试次数 × 2,上限为 MaxBackoffThreshold
int attempts = _reconnectAttempts.GetOrAdd(deviceName, 0);
int currentThreshold = Math.Min(BaseFailureThreshold + attempts * 2, MaxBackoffThreshold);
if (failures < currentThreshold)
{
LoggerHelper.Warn(
$"[{_scopeName}] 设备 [{deviceName}] 连接断开,等待重连中 ({failures}/{currentThreshold})");
continue;
}
// 达到阈值:执行重连
await ReconnectDeviceAsync(deviceName, device);
}
catch (Exception ex)
{
LoggerHelper.Error($"[{_scopeName}] 设备 [{deviceName}] 健康检查异常:{ex.Message}");
}
}
}
/// <summary>
/// 对 TCP 设备执行轻量级主动探活(SCPI *IDN?),2 秒超时。
/// <para>通过 WriteReadAsync 内部获取 _commLock,与监控采样天然串行化。</para>
/// </summary>
/// <returns>true: 探活成功(连接确实存活); false: 探活失败(死连接)</returns>
private async Task<bool> ProbeTcpDeviceAsync(Tcp tcpDevice)
{
try
{
using var probeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
string resp = await tcpDevice.WriteReadAsync("*IDN?\n", "\n", probeCts.Token);
return !string.IsNullOrWhiteSpace(resp);
}
catch
{
return false;
}
}
/// <summary>
/// 重连单个设备。
/// <para>
/// 冲突避免机制:ConnectAsync 内部获取设备的 _commLock
/// 如果此时监控采样正在通信,重连会等待锁释放后再执行,
/// 保证同一时刻只有一个操作在使用通信链路。
/// </para>
/// </summary>
private async Task ReconnectDeviceAsync(string deviceName, IBaseInterface device)
{
try
{
int attempts = _reconnectAttempts.AddOrUpdate(deviceName, 1, (_, c) => c + 1);
int nextThreshold = Math.Min(BaseFailureThreshold + attempts * 2, MaxBackoffThreshold);
LoggerHelper.Info($"[{_scopeName}] 设备 [{deviceName}] 连续失败触发重连(第 {attempts} 次重连,下次阈值={nextThreshold}...");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
bool ok = await device.ConnectAsync(cts.Token);
// 同步更新 DeviceInfoVM 的 UI 状态
UpdateDeviceInfoState(deviceName, ok);
if (ok)
{
_failureCounts.TryRemove(deviceName, out _);
_reconnectAttempts.TryRemove(deviceName, out _);
LoggerHelper.Info($"[{_scopeName}] 设备 [{deviceName}] 重连成功");
}
else
{
LoggerHelper.Warn($"[{_scopeName}] 设备 [{deviceName}] 重连失败,将在下次检查时重试");
}
}
catch (OperationCanceledException)
{
UpdateDeviceInfoState(deviceName, false);
LoggerHelper.Warn($"[{_scopeName}] 设备 [{deviceName}] 重连超时(10s),将在下次检查时重试");
}
catch (Exception ex)
{
UpdateDeviceInfoState(deviceName, false);
LoggerHelper.Error($"[{_scopeName}] 设备 [{deviceName}] 重连异常:{ex.Message}");
}
}
/// <summary>
/// 通过 UI 线程更新 SystemConfig.DeviceList 中对应设备的 IsConnected 状态(驱动 UI 刷新)。
/// <para>Timer 回调运行在线程池线程上,必须切回 UI 线程才能触发 PropertyChanged。</para>
/// </summary>
private void UpdateDeviceInfoState(string deviceName, bool isConnected)
{
var info = _systemConfig?.DeviceList?
.FirstOrDefault(d => d != null &&
string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase));
if (info == null) return;
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher == null || dispatcher.CheckAccess())
{
// 已在 UI 线程(或无 Dispatcher),直接赋值
info.IsConnected = isConnected;
}
else
{
// 切回 UI 线程赋值,避免跨线程 PropertyChanged 异常
dispatcher.BeginInvoke(() => info.IsConnected = isConnected);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
}
}
}
+24
View File
@@ -28,6 +28,9 @@ namespace UIShare.GlobalVariable
private readonly string _scopeName;
private readonly IEventAggregator _eventAggregator;
/// <summary>设备健康监控器:独立心跳检测 + 自动重连,与监控采样互不冲突</summary>
private DeviceHealthMonitor? _healthMonitor;
/// <summary>按 DeviceName 索引的设备字典,便于业务层按名取实例。</summary>
public IDictionary<string, IBaseInterface> DeviceMap { get; private set; }
= new Dictionary<string, IBaseInterface>(StringComparer.OrdinalIgnoreCase);
@@ -252,6 +255,9 @@ namespace UIShare.GlobalVariable
}
await Task.WhenAll(tasks);
// 所有设备连接完成后,启动健康监控(心跳重连)
StartHealthMonitor();
}
@@ -320,6 +326,7 @@ namespace UIShare.GlobalVariable
/// </summary>
public async Task CloseAllDevicesAsync()
{
StopHealthMonitor();
List<Task> tasks = new List<Task>();
lock (_lockObj)
@@ -617,8 +624,25 @@ namespace UIShare.GlobalVariable
return Activator.CreateInstance(type, cfg) as IBaseInterface;
}
/// <summary>启动设备健康监控(心跳检测 + 自动重连)</summary>
private void StartHealthMonitor()
{
StopHealthMonitor();
_healthMonitor = new DeviceHealthMonitor(DeviceMap, _systemConfig, _scopeName);
_healthMonitor.Start();
}
/// <summary>停止设备健康监控</summary>
private void StopHealthMonitor()
{
_healthMonitor?.Stop();
_healthMonitor?.Dispose();
_healthMonitor = null;
}
public void Dispose()
{
StopHealthMonitor();
if (string.IsNullOrEmpty(_scopeName)) return;
// 遍历当前作用域用到的所有指纹,逐一移除本作用域的引用
+8 -4
View File
@@ -12,10 +12,14 @@ namespace UIShare.GlobalVariable
public class GlobalInfo:BindableBase
{
public event EventHandler? ScopeChanged;
public Dictionary<string,ScopedContext> ContextDic { get; set; }
public Dictionary<string,StepRunning> StepRunningDic { get; set; }
public Dictionary<string, SystemConfig> ConfigDic { get; set; }
public Dictionary<string, IScopedProvider> ScopeDic { get; set; }
/// <summary>作用域名 → 作用域上下文(线程安全:可能被多个作用域并发创建/销毁)</summary>
public ConcurrentDictionary<string, ScopedContext> ContextDic { get; set; }
/// <summary>作用域名 → 测试运行器(线程安全:可能被多个作用域并发创建/销毁)</summary>
public ConcurrentDictionary<string, StepRunning> StepRunningDic { get; set; }
/// <summary>作用域名 → 系统配置(线程安全:可能被多个作用域并发创建/销毁)</summary>
public ConcurrentDictionary<string, SystemConfig> ConfigDic { get; set; }
/// <summary>作用域名 → 作用域容器(线程安全:可能被多个作用域并发创建/销毁)</summary>
public ConcurrentDictionary<string, IScopedProvider> ScopeDic { get; set; }
/// <summary>硬件指纹 → 设备实例的并发池,确保同一物理硬件全局只创建一个驱动实例。</summary>
public ConcurrentDictionary<string, Lazy<IBaseInterface>> HardwarePool { get; set; }
@@ -1,8 +1,10 @@
using Common.Attributes;
using DeviceCommand.Base;
using Logger;
using Model.Models;
using Prism.Events;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
@@ -31,16 +33,28 @@ namespace UIShare.GlobalVariable
/// <summary>
/// 已注册的采样项:(Fingerprint, MethodName, 编译后的委托, DeviceInstance)
/// 委托签名统一为 Func&lt;object, CancellationToken, Task&lt;string&gt;&gt;
/// 委托签名统一为 Func&lt;object, CancellationToken, Task&lt;double&gt;&gt;
/// - 第一个参数 = 设备实例(open delegate 风格,编译时已做类型转换)
/// - 第二个参数 = CancellationToken(无参方法会忽略它)
/// - 返回值 = Task&lt;string&gt;
/// - 返回值 = Task&lt;double&gt;Task&lt;string&gt; 方法自动 TryParse 为 double
/// </summary>
private readonly List<(string Fingerprint, string MethodName, Func<object, CancellationToken, Task<string>> Invoker, object Device)> _registeredMethods = new();
private readonly List<(string Fingerprint, string MethodName, Func<object, CancellationToken, Task<double>> Invoker, object Device)> _registeredMethods = new();
private CancellationTokenSource? _cts;
private bool _disposed;
/// <summary>是否已完成过至少一次 Discover(幂等标记,避免多作用域重复调用时 Clear + 重扫)</summary>
private bool _discovered;
/// <summary>连续失败次数达到此阈值后,暂停该通道采样并发布报警</summary>
private const int FailureThreshold = 5;
/// <summary>每通道连续失败计数(key = fingerprint + "|" + methodName</summary>
private readonly ConcurrentDictionary<string, int> _failureCounts = new();
/// <summary>已因连续失败而被暂停的通道(key 同上)</summary>
private readonly ConcurrentDictionary<string, bool> _suspendedChannels = new();
/// <summary>采样间隔(默认 1000ms</summary>
public TimeSpan SampleInterval
{
@@ -62,11 +76,13 @@ namespace UIShare.GlobalVariable
/// <summary>
/// 将 MethodInfo 编译为强类型委托,后续调用零反射开销。
/// 统一签名为 Func&lt;object, CancellationToken, Task&lt;string&gt;&gt;
/// 统一签名为 Func&lt;object, CancellationToken, Task&lt;double&gt;&gt;
/// - Task&lt;double&gt; 方法:直接返回 double 值
/// - Task&lt;string&gt; 方法:自动 TryParse 为 double
/// - 无参方法:忽略 CancellationToken
/// - 带 CancellationToken 方法:直接透传
/// </summary>
private static Func<object, CancellationToken, Task<string>> BuildInvoker(MethodInfo method)
private static Func<object, CancellationToken, Task<double>> BuildInvoker(MethodInfo method)
{
var deviceParam = Expression.Parameter(typeof(object), "device");
var ctParam = Expression.Parameter(typeof(CancellationToken), "ct");
@@ -82,16 +98,35 @@ namespace UIShare.GlobalVariable
var call = Expression.Call(castDevice, method, callArgs);
return Expression.Lambda<Func<object, CancellationToken, Task<string>>>(
if (method.ReturnType == typeof(Task<double>))
{
// Task<double> 方法:直接返回
return Expression.Lambda<Func<object, CancellationToken, Task<double>>>(
call, deviceParam, ctParam).Compile();
}
else
{
// Task<string> 方法:await 后 TryParse 为 double
return async (device, ct) =>
{
// 通过 MethodInfo.Invoke 调用(仅 Task<string> 路径,性能可接受)
var task = (Task<string>)method.Invoke(device,
parms.Length == 1 ? new object[] { ct } : Array.Empty<object>())!;
string raw = await task.ConfigureAwait(false);
return double.TryParse(raw, out double v) ? v : double.NaN;
};
}
}
/// <summary>
/// 扫描 GlobalInfo.HardwarePool 中所有已创建的物理设备,
/// 反查可监测方法并编译为委托。每个指纹只注册一次,反射只执行一次。
/// <para>幂等:首次调用后再次调用不会 Clear 重扫,避免多作用域重复 Discover 导致短暂采样中断。</para>
/// </summary>
public void Discover()
{
if (_discovered) return;
_discovered = true;
_registeredMethods.Clear();
foreach (var poolEntry in _globalInfo.HardwarePool)
@@ -109,7 +144,9 @@ namespace UIShare.GlobalVariable
.Where(m =>
{
if (m.GetCustomAttribute<MonitorableAttribute>() == null) return false;
if (m.ReturnType != typeof(Task<string>)) return false;
// 同时支持 Task<string> 和 Task<double> 返回类型
var rt = m.ReturnType;
if (rt != typeof(Task<string>) && rt != typeof(Task<double>)) return false;
var parms = m.GetParameters();
return parms.Length == 0 ||
(parms.Length == 1 && parms[0].ParameterType == typeof(CancellationToken));
@@ -157,15 +194,23 @@ namespace UIShare.GlobalVariable
foreach (var entry in _registeredMethods)
{
string channelKey = entry.Fingerprint + "|" + entry.MethodName;
// 已暂停的通道跳过采样
if (_suspendedChannels.ContainsKey(channelKey)) continue;
// fire-and-forget:每个通道独立采样,完成后自行广播
_ = Task.Run(async () =>
{
try
{
// 直接委托调用,零反射开销
string raw = await entry.Invoker(entry.Device, token).ConfigureAwait(false);
// 直接委托调用,零反射开销Task<double> 路径)或自动 TryParseTask<string> 路径)
double value = await entry.Invoker(entry.Device, token).ConfigureAwait(false);
if (!double.TryParse(raw, out double value)) return;
if (double.IsNaN(value)) return;
// 采样成功,重置失败计数
_failureCounts.TryRemove(channelKey, out _);
// 向所有引用该物理设备的作用域分别广播
var scopes = GetScopesForFingerprint(entry.Fingerprint);
@@ -188,9 +233,24 @@ namespace UIShare.GlobalVariable
}
}
}
catch
catch (Exception ex)
{
// 单个通道故障不干扰其他通道
int count = _failureCounts.AddOrUpdate(channelKey, 1, (_, c) => c + 1);
LoggerHelper.Warn($"采样通道 [{entry.Fingerprint}/{entry.MethodName}] 第 {count} 次失败: {ex.Message}");
if (count >= FailureThreshold)
{
_suspendedChannels.TryAdd(channelKey, true);
LoggerHelper.Error($"采样通道 [{entry.Fingerprint}/{entry.MethodName}] 连续失败 {count} 次,已暂停采样");
// 向所有引用该设备的作用域发布报警
var scopes = GetScopesForFingerprint(entry.Fingerprint);
foreach (var scope in scopes)
{
_eventAggregator.GetEvent<AlarmEvent>().Publish(
(scope, entry.Fingerprint, $"通道 {entry.MethodName} 连续失败 {count} 次,已暂停"));
}
}
}
}, token);
}
@@ -209,6 +269,8 @@ namespace UIShare.GlobalVariable
_cts?.Dispose();
_cts = null;
_registeredMethods.Clear();
_failureCounts.Clear();
_suspendedChannels.Clear();
}
}
}
@@ -0,0 +1,89 @@
using Prism.Mvvm;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using UIShare.UIViewModel;
namespace UIShare.GlobalVariable
{
/// <summary>
/// 单个 Tab(主程序 / 错误程序)的子程序导航状态。
/// 主程序和错误程序各自持有一个独立实例,互不干扰。
/// </summary>
public class ProgramNavigationState : BindableBase
{
/// <summary>是否为错误程序 Tab</summary>
public bool IsErrorTab { get; set; }
public Stack<ProgramVM> NavigationStack { get; } = new();
private ProgramVM _currentProgram;
public ProgramVM CurrentProgram
{
get => _currentProgram;
set
{
if (SetProperty(ref _currentProgram, value))
RaisePropertyChanged(nameof(DisplaySteps));
}
}
/// <summary>
/// DataGrid 实际绑定的步骤集合。
/// 根程序 + 错误 Tab → ErrorStepCollection;其他情况 → StepCollection。
/// </summary>
public ObservableCollection<StepVM> DisplaySteps
{
get
{
if (CurrentProgram == null)
return new ObservableCollection<StepVM>();
// 在根程序且是错误 Tab → 显示错误步骤集合
if (NavigationStack.Count == 0 && IsErrorTab)
return CurrentProgram.ErrorStepCollection;
// 其他情况(主 Tab 或已进入子程序)→ 显示正常步骤集合
return CurrentProgram.StepCollection;
}
}
private string _breadcrumbPath = "主程序";
public string BreadcrumbPath
{
get => _breadcrumbPath;
set => SetProperty(ref _breadcrumbPath, value);
}
private bool _canGoBack;
public bool CanGoBack
{
get => _canGoBack;
set => SetProperty(ref _canGoBack, value);
}
/// <summary>
/// 用根程序初始化导航状态
/// </summary>
public void Initialize(ProgramVM rootProgram, string label)
{
NavigationStack.Clear();
CurrentProgram = rootProgram;
BreadcrumbPath = label;
CanGoBack = false;
}
/// <summary>
/// 清空导航栈,回到根程序
/// </summary>
public void Reset(ProgramVM rootProgram, string label)
{
NavigationStack.Clear();
CurrentProgram = rootProgram;
BreadcrumbPath = label;
CanGoBack = false;
}
/// <summary>
/// 根据栈状态刷新 CanGoBack
/// </summary>
public void UpdateCanGoBack() => CanGoBack = NavigationStack.Count > 0;
}
}
+29 -1
View File
@@ -1,5 +1,6 @@
using DeviceCommand.Base;
using MaterialDesignThemes.Wpf;
using Prism.Mvvm;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
@@ -14,9 +15,35 @@ using UIShare.UIViewModel;
namespace UIShare.GlobalVariable
{
public class ScopedContext
public class ScopedContext : BindableBase
{
private static readonly Random _randomSeed = new Random();
#region /
/// <summary>主程序 Tab 的导航状态</summary>
public ProgramNavigationState MainNav { get; } = new();
/// <summary>错误程序 Tab 的导航状态</summary>
public ProgramNavigationState ErrorNav { get; } = new();
/// <summary>
/// 根据当前选中的 Tab 返回对应的导航状态
/// </summary>
public ProgramNavigationState ActiveNav =>
SelectedStepList == "错误程序" ? ErrorNav : MainNav;
/// <summary>
/// 重置两个 Tab 的导航栈,回到根程序
/// </summary>
public void ResetNavigation()
{
MainNav.Reset(Program, "主程序");
ErrorNav.Reset(Program, "错误程序");
}
#endregion
public ProgramVM Program { get; set; } = new();
public String SelectedStepList { get; set; } = "主程序";
public string CurrentFilePath { get; set; }
@@ -45,6 +72,7 @@ namespace UIShare.GlobalVariable
public int DebugRandomId { get; private set; }
public ScopedContext()
{
ErrorNav.IsErrorTab = true;
lock (_randomSeed)
{
// 每次诞生一个新上下文,就在 10000 到 99999 之间随机摇一个数
+213 -2
View File
@@ -13,6 +13,7 @@ using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using UIShare.GlobalVariable;
using static UIShare.UIViewModel.ParameterVM;
@@ -29,6 +30,7 @@ namespace UIShare
private IContainerProvider containerProvider;
private IEventAggregator _eventAggregator;
private ITestReportService _testReportService;
private ITestCheckRecordService _testCheckRecordService;
private readonly Dictionary<Guid, ParameterVM> tmpParameters = [];
@@ -38,6 +40,9 @@ namespace UIShare
private readonly Stack<LoopContext> loopStack = new();
/// <summary>测试项上下文栈:进入 IsTestItem 子程序时压栈,栈非空期间每次 OKExpression 判断都归属栈顶测试项</summary>
private readonly Stack<TestItemContext> testItemStack = new();
public CancellationTokenSource stepCTS = new();
public CancellationTokenSource errorStepCTS = new();
private bool SubSingleStep = false;
@@ -46,13 +51,14 @@ namespace UIShare
private volatile bool _disposed = false;
public Guid TestRoundID;
public StepRunning(ScopedContext ScopedContext, SystemConfig systemConfig,IEventAggregator eventAggregator, DeviceManager deviceManager, ITestReportService testReportService)
public StepRunning(ScopedContext ScopedContext, SystemConfig systemConfig,IEventAggregator eventAggregator, DeviceManager deviceManager, ITestReportService testReportService, ITestCheckRecordService testCheckRecordService)
{
_scopedContext = ScopedContext;
_systemConfig = systemConfig;
_eventAggregator = eventAggregator;
_deviceManager= deviceManager;
_testReportService = testReportService;
_testCheckRecordService = testCheckRecordService;
//_devices = containerProvider.Resolve<Devices>();
}
public async Task<bool> ExecuteErrorSteps(ProgramVM program, int depth = 0, CancellationToken cancellationToken = default)
@@ -67,6 +73,8 @@ namespace UIShare
ResetAllStepStatus(program.ErrorStepCollection);
tmpParameters.Clear();
TestRoundID = Guid.NewGuid();
// 注入步骤耗时查询器:AsyncLocal 随异步执行流流动,命令库(CommandRunningTime)据此查询当前作用域主程序的步骤耗时
StepRuntimeContext.StepTimeQuery.Value = QueryStepRunTimeSum;
}
foreach (var item in program.Parameters)
{
@@ -181,7 +189,23 @@ namespace UIShare
_scopedContext.SingleStep = false;
}
LoggerHelper.InfoWithNotify(_systemConfig.Title, $"开始执行子程序 [ {step.Index} ] [ {step.Name} ] ", depth);
// 发布进入子程序导航事件
_eventAggregator.GetEvent<SubProgramNavigateEvent>().Publish(new SubProgramNavigatePayload
{
Scope = _systemConfig.Title,
Action = NavigateAction.Enter,
SubProgram = step.SubProgram,
StepName = step.Name,
IsErrorProgram = true
});
stepSuccess = await ExecuteSteps(step.SubProgram, depth + 1, cancellationToken);
// 发布退出子程序导航事件
_eventAggregator.GetEvent<SubProgramNavigateEvent>().Publish(new SubProgramNavigatePayload
{
Scope = _systemConfig.Title,
Action = NavigateAction.Exit,
IsErrorProgram = true
});
UpdateCurrentStepResult(step, true, stepSuccess, depth);
if (SubSingleStep)
{
@@ -237,8 +261,12 @@ namespace UIShare
loopStopwatchStack.Clear();
ResetAllStepStatus(program.StepCollection);
tmpParameters.Clear();
testItemStack.Clear();
TestRoundID = Guid.NewGuid();
// 注入步骤耗时查询器:AsyncLocal 随异步执行流流动,命令库(CommandRunningTime)据此查询当前作用域主程序的步骤耗时
StepRuntimeContext.StepTimeQuery.Value = QueryStepRunTimeSum;
}
int initialLoopStackCount = loopStack.Count;
foreach (var item in program.Parameters)
{
tmpParameters.TryAdd(item.ID, item);
@@ -356,8 +384,32 @@ namespace UIShare
_scopedContext.SingleStep = false;
}
LoggerHelper.InfoWithNotify(_systemConfig.Title, $"开始执行子程序 [ {step.Index} ] [ {step.Name} ] ", depth);
// 发布进入子程序导航事件
_eventAggregator.GetEvent<SubProgramNavigateEvent>().Publish(new SubProgramNavigatePayload
{
Scope = _systemConfig.Title,
Action = NavigateAction.Enter,
SubProgram = step.SubProgram,
StepName = step.Name
});
bool isTestItemStep = step.IsTestItem;
if (isTestItemStep)
{
testItemStack.Push(new TestItemContext { Name = step.Name ?? "未命名测试项" });
}
stepSuccess = await ExecuteSteps(step.SubProgram, depth + 1, cancellationToken);
// 先评估本步骤自身(含自身 OKExpression 判断,归属本测试项),再写测试项汇总并弹栈
UpdateCurrentStepResult(step, true, stepSuccess, depth);
if (isTestItemStep)
{
await FinalizeTestItemAsync(depth);
}
// 发布退出子程序导航事件
_eventAggregator.GetEvent<SubProgramNavigateEvent>().Publish(new SubProgramNavigatePayload
{
Scope = _systemConfig.Title,
Action = NavigateAction.Exit
});
if (SubSingleStep)
{
SubSingleStep = false;
@@ -405,7 +457,12 @@ namespace UIShare
await SaveStepRecordAsync(step, depth, false);
}
}
bool finalResult = loopStack.Count == initialLoopStackCount && stepSuccess;
if (depth > 0) // 子程序
{
return finalResult;
}
return loopStack.Count == 0 && stepSuccess;
}
@@ -416,7 +473,11 @@ namespace UIShare
{
if(_scopedContext.Program.StepCollection.Count>1)
_scopedContext.SelectedStep = null;
// 区间延迟不计入步骤运行时间:延迟期间暂停步骤计时器(子程序内的区间延迟同样排除,它们都归属顶层步骤的计时区间)
bool timingPaused = stepStopwatch.IsRunning;
if (timingPaused) stepStopwatch.Stop();
await Task.Delay(_systemConfig.PerformanceLevel, cancellationToken);
if (timingPaused) stepStopwatch.Start();
// 1. 查找类型
@@ -747,6 +808,30 @@ namespace UIShare
}
}
/// <summary>
/// 查询当前作用域主程序步骤集合中,序号位于 [起始序号, 结束序号](含两端)范围内步骤的运行时间之和(毫秒)。
/// <para>由 ExecuteSteps / ExecuteErrorSteps 顶层启动时经 StepRuntimeContext 注入(AsyncLocal 随异步流流动),
/// 供命令库 CommandRunningTime.获取步骤运行时间之和 调用(依赖倒置:命令库不引用宿主类型)。</para>
/// <para>序号倒置、超界(范围内不存在步骤)时记录错误日志并返回 0;未执行步骤的耗时按 0 计入。</para>
/// </summary>
private double QueryStepRunTimeSum(int startIndex, int endIndex)
{
var steps = _scopedContext.Program.StepCollection;
if (startIndex > endIndex)
{
LoggerHelper.Error($"[步骤耗时] 查询失败:起始序号 {startIndex} 大于结束序号 {endIndex},返回 0");
return 0;
}
var matched = steps.Where(s => s.Index >= startIndex && s.Index <= endIndex).ToList();
if (matched.Count == 0)
{
LoggerHelper.Error($"[步骤耗时] 查询失败:序号 {startIndex}~{endIndex} 范围内不存在测试步骤(主程序共 {steps.Count} 步),返回 0");
return 0;
}
// 尚未执行或未启用的步骤 RunTime 为 null,按 0 计入
return matched.Sum(s => s.RunTime ?? 0);
}
private void UpdateCurrentStepResult(StepVM step, bool paraResult = true, bool stepResult = true, int depth = 0)
{
if (stepResult && paraResult)
@@ -776,12 +861,46 @@ namespace UIShare
paraDic.TryAdd(item.Name, item.Value!);
}
}
bool re = ExpressionEvaluator.EvaluateExpression(step.OKExpression, paraDic);
bool re;
try
{
re = ExpressionEvaluator.EvaluateExpression(step.OKExpression, paraDic);
}
catch (Exception ex)
{
// 表达式执行错误也视为 NG,并记录到测试项判断明细
LoggerHelper.ErrorWithNotify(_systemConfig.Title, $"指令 [ {step.Index} ] OKExpression 执行异常: {ex.Message}", depth: depth);
re = false;
}
step.Result = re ? 1 : 2;
if (step.Result == 2)
{
LoggerHelper.WarnWithNotify(_systemConfig.Title, $"指令 [ {step.Index} ] NG:条件表达式验证失败", depth: depth);
}
// 测试项范围内:记录本次判断(重复判断逐次记录)
if (testItemStack.Count > 0)
{
var ctx = testItemStack.Peek();
bool isSelfCheck = ctx.Name == (step.Name ?? "未命名测试项") && step.SubProgram != null;
if (!re) ctx.HasFailure = true;
if (!isSelfCheck)
{
SaveCheckRecordAsync(new TestCheckRecordEntity
{
TestRoundId = TestRoundID,
Scope = _systemConfig.Title,
FileName = _systemConfig.CurrentADPFile ?? "",
TestItemName = ctx.Name,
StepName = step.Name ?? "",
Depth = depth,
OKExpression = step.OKExpression,
Pass = re,
Values = ExtractExpressionValues(step.OKExpression, paraDic),
IsSummary = false,
CreateTime = DateTime.Now
});
}
}
}
}
else
@@ -792,6 +911,90 @@ namespace UIShare
}
step.Result = 2;
}
// 测试项范围内的步骤执行错误/表达式 NG 均计入当前测试项汇总(自身步骤的错误已在压栈期间计入)
if (step.Result == 2 && testItemStack.Count > 0)
{
testItemStack.Peek().HasFailure = true;
}
}
/// <summary>
/// 测试项执行结束:写入汇总记录(PASS/NG)并弹栈。
/// 测试项内无任何判断时,结果由范围内步骤执行成败决定。
/// </summary>
private async Task FinalizeTestItemAsync(int depth)
{
if (testItemStack.Count == 0) return;
var ctx = testItemStack.Pop();
SaveCheckRecordAsync(new TestCheckRecordEntity
{
TestRoundId = TestRoundID,
Scope = _systemConfig.Title,
FileName = _systemConfig.CurrentADPFile ?? "",
TestItemName = ctx.Name,
StepName = ctx.Name,
Depth = depth,
OKExpression = null,
Pass = !ctx.HasFailure,
Values = null,
IsSummary = true,
CreateTime = DateTime.Now
});
await Task.CompletedTask;
}
/// <summary>
/// 保存测试项判断记录(不阻塞执行主流程)
/// </summary>
private void SaveCheckRecordAsync(TestCheckRecordEntity entity)
{
_ = Task.Run(async () =>
{
try
{
var result = await _testCheckRecordService.InsertAsync(entity);
if (!result.IsSuccess)
{
LoggerHelper.Error($"保存测试项判断记录失败 [{entity.TestItemName}]: {result.Msg}");
}
}
catch (Exception ex)
{
LoggerHelper.Error($"保存测试项判断记录失败 [{entity.TestItemName}]: {ex.Message}");
}
});
}
/// <summary>
/// 从表达式中提取实际出现的变量并取其当前值,拼接为 "变量=值; " 格式(长名优先,避免子串误匹配)
/// </summary>
private static string? ExtractExpressionValues(string expression, Dictionary<string, object> paraDic)
{
try
{
var sb = new StringBuilder();
var matchedSpans = new List<(int Start, int End)>();
foreach (var name in paraDic.Keys.OrderByDescending(k => k.Length))
{
if (string.IsNullOrWhiteSpace(name)) continue;
foreach (Match m in Regex.Matches(expression, $@"\b{Regex.Escape(name)}\b"))
{
bool overlaps = matchedSpans.Any(s => m.Index < s.End && m.Index + m.Length > s.Start);
if (!overlaps)
{
matchedSpans.Add((m.Index, m.Index + m.Length));
sb.Append($"{name}={paraDic[name]}; ");
break;
}
}
}
return sb.Length > 0 ? sb.ToString() : null;
}
catch
{
return null;
}
}
public void Dispose()
@@ -825,6 +1028,7 @@ namespace UIShare
tmpParameters.Clear();
loopStack.Clear();
loopStopwatchStack.Clear();
testItemStack.Clear();
stepStopwatch.Stop();
}
@@ -840,6 +1044,13 @@ namespace UIShare
public StepVM? LoopStartStep { get; set; }
}
/// <summary>测试项执行上下文:记录测试项名称与范围内是否出现过失败</summary>
private class TestItemContext
{
public string Name { get; set; } = string.Empty;
public bool HasFailure { get; set; }
}
#endregion
}
+3 -11
View File
@@ -56,23 +56,15 @@ namespace UIShare.GlobalVariable
{
Category = ParameterCategory.Input,
Type = typeof(int),
Name = "直流负载通道",
Value = 1,
Name = "CAN通道1",
Value = 0,
IsEditable=false
},
new ParameterVM
{
Category = ParameterCategory.Input,
Type = typeof(int),
Name = "交流电源通道",
Value = 1,
IsEditable=false
},
new ParameterVM
{
Category = ParameterCategory.Input,
Type = typeof(int),
Name = "CAN通道",
Name = "CAN通道2",
Value = 0,
IsEditable=false
},
+18
View File
@@ -9,4 +9,22 @@ namespace UIShare.PubEvent
public class OverlayEvent : PubSubEvent<bool>
{
}
/// <summary>
/// 作用域感知的灰度遮罩事件:仅台架名称与 Scope 匹配的台架视图显示/隐藏加载遮罩,
/// 避免全局 OverlayEvent 导致所有台架及主窗口同时变灰。
/// </summary>
public class ScopeOverlayEvent : PubSubEvent<ScopeOverlayArgs>
{
}
/// <summary>作用域遮罩事件参数。</summary>
public class ScopeOverlayArgs
{
/// <summary>台架名称(与 SystemConfig.Title / TestStatus 一致)。</summary>
public string Scope { get; set; } = string.Empty;
/// <summary>true 显示遮罩,false 隐藏遮罩。</summary>
public bool Show { get; set; }
}
}
@@ -0,0 +1,37 @@
using Prism.Events;
using UIShare.UIViewModel;
namespace UIShare.PubEvent
{
/// <summary>
/// 子程序导航事件:运行时进入/退出子程序时发布,
/// StepsManagerViewModel 订阅后自动切换显示。
/// </summary>
public class SubProgramNavigateEvent : PubSubEvent<SubProgramNavigatePayload>
{
}
public class SubProgramNavigatePayload
{
/// <summary>目标台架的作用域标识</summary>
public string Scope { get; set; } = "";
/// <summary>Enter = 进入子程序,Exit = 退出回到上一级</summary>
public NavigateAction Action { get; set; }
/// <summary>进入时:子程序的 ProgramVM;退出时可为 null</summary>
public ProgramVM? SubProgram { get; set; }
/// <summary>子程序步骤的名称(用于面包屑显示)</summary>
public string? StepName { get; set; }
/// <summary>是否为错误程序 Tab 的导航(默认 false = 主程序 Tab</summary>
public bool IsErrorProgram { get; set; }
}
public enum NavigateAction
{
Enter,
Exit
}
}
+8
View File
@@ -7,6 +7,7 @@
private int _报文ID;
private double _时间戳;
private byte _长度;
private string _数据;
// 通道属性
@@ -37,6 +38,13 @@
set { SetProperty(ref _长度, value); }
}
// 数据属性(原始报文字节十六进制字符串,如 [0x00, 0x01, ...]
public string
{
get { return _数据; }
set { SetProperty(ref _数据, value); }
}
}
}
+7
View File
@@ -32,5 +32,12 @@ namespace UIShare.UIViewModel
get => _tag;
set => SetProperty(ref _tag, value);
}
private string? _tooltip;
public string? Tooltip
{
get => _tooltip;
set => SetProperty(ref _tooltip, value);
}
}
}
+10
View File
@@ -26,6 +26,7 @@ namespace UIShare.UIViewModel
NGGotoStepID = source.NGGotoStepID;
Description = source.Description;
IsUsed = source.IsUsed;
IsTestItem = source.IsTestItem;
if (source.Method != null)
{
@@ -55,6 +56,15 @@ namespace UIShare.UIViewModel
set => SetProperty(ref _isUsed, value);
}
private bool _isTestItem = false;
/// <summary>是否测试项(仅子程序步骤可标记,运行时记录其范围内所有 OKExpression 判断)</summary>
public bool IsTestItem
{
get => _isTestItem;
set => SetProperty(ref _isTestItem, value);
}
private int _index;
public int Index
+212 -11
View File
@@ -11,6 +11,11 @@ using System.Threading.Tasks;
namespace ZLGUSBCANFD
{
/// <summary>
/// 周立功 USBCANFD 系列 CAN 卡控制类(如 USBCANFD-400U),封装设备连接、通道初始化、
/// DBC 矩阵加载与解码、报文发送(单次/循环)、信号物理值设置等功能。
/// 每个通道拥有独立的 DBC 引擎句柄与细粒度锁,支持多台架并行互不干扰。
/// </summary>
[ADPCommand]
public class ZLGCANFD : IDisposable
{
@@ -35,6 +40,10 @@ namespace ZLGUSBCANFD
// 每次 设置报文 会累积写入此表,发送时以 DBC 初始值为底再叠加此表覆盖值
private readonly ConcurrentDictionary<(uint , uint ID), Dictionary<string, double>> _signalOverrides = new ConcurrentDictionary<(uint, uint), Dictionary<string, double>>();
// 最新解码报文缓存:Key = (通道号, 帧ID)Value = 最近一次 DBC 解码成功的报文快照(含各信号原始值)
// 接收线程解码成功时写入,供 获取报文信号值 主动查询最新信号物理值
private readonly ConcurrentDictionary<(uint , uint ID), ZDBC.DBCMessage> _latestDecodedMessages = new ConcurrentDictionary<(uint, uint), ZDBC.DBCMessage>();
// 动态硬件参数
private readonly uint _deviceType; // 76: USBCANFD-400U
private readonly uint _deviceIndex; // 设备索引
@@ -56,6 +65,22 @@ namespace ZLGUSBCANFD
/// </summary>
public event Action<uint, ZDBC.DBCMessage>? OnDbcMessageDecoded;
/// <summary>
/// 事件:当接收到任意 CAN/CANFD 原始报文时触发(不依赖 DBC,自定义报文也能收到)
/// 参数:通道号, 帧ID, 数据, 时间戳(微秒), 是否FD, 数据长度
/// </summary>
public event Action<uint, uint, byte[], ulong, bool, byte>? OnRawMessageReceived;
/// <summary>
/// 构造函数,配置设备类型、索引、通道数及所有通道共用的波特率与终端电阻参数,
/// 并初始化通道状态数组与 DBC 解析器。
/// </summary>
/// <param name="deviceType">设备类型,76 为 USBCANFD-400U</param>
/// <param name="deviceIndex">设备索引,多台同型号设备时区分,默认 0</param>
/// <param name="maxChannels">通道数量,默认 4</param>
/// <param name="abitBaud">仲裁域波特率,默认 "500000"</param>
/// <param name="dbitBaud">数据域波特率(仅 CANFD 生效),默认 "2000000"</param>
/// <param name="enableTerminalResistance">是否启用通道内部终端电阻,默认 true</param>
public ZLGCANFD(uint deviceType = 76, uint deviceIndex = 0, int maxChannels = 4,
string abitBaud = "500000", string dbitBaud = "2000000", bool enableTerminalResistance = true)
{
@@ -85,8 +110,9 @@ namespace ZLGUSBCANFD
#region 1.
/// <summary>
/// 仅仅打开设备,不做具体的通道波特率配置(留给具体的台架去分别配置)
/// 仅仅打开设备,不做具体的通道波特率配置(留给具体的台架去分别配置)
/// </summary>
/// <returns>是否打开成功;设备已打开时重复调用直接返回 true</returns>
public virtual bool ()
{
if (_deviceHandle != IntPtr.Zero) return true;
@@ -95,8 +121,12 @@ namespace ZLGUSBCANFD
}
/// <summary>
/// 针对特定通道进行参数初始化并启动(使用构造时传入的波特率与终端电阻配置)
/// 针对特定通道进行参数初始化并启动(使用构造时传入的波特率与终端电阻配置)
/// 同时为该通道启动专属的后台高性能接收轮询线程。
/// </summary>
/// <param name="通道号">通道索引 (0 起始)</param>
/// <returns>是否初始化并启动成功;通道已启动时重复调用直接返回 true</returns>
/// <exception cref="InvalidOperationException">未先调用“打开设备()”时抛出</exception>
public virtual bool (uint )
{
_isClosing = false; // 重置关闭标志,允许循环发送
@@ -151,6 +181,10 @@ namespace ZLGUSBCANFD
}
}
/// <summary>
/// 关闭 CAN 卡设备:先停止所有循环发送与接收轮询线程,再复位全部通道、
/// 释放各通道 DBC 资源,最后关闭设备主句柄。
/// </summary>
public virtual void CAN卡设备()
{
_isClosing = true; // 通知循环发送任务尽快退出
@@ -212,8 +246,11 @@ namespace ZLGUSBCANFD
#region 2. DBC
/// <summary>
/// 指定通道加载特定的 DBC 文件(支持不同通道加载不同的DBC矩阵)
/// 指定通道加载特定的 DBC 文件(支持不同通道加载不同的DBC矩阵)
/// </summary>
/// <param name="通道号">通道索引 (0 起始)</param>
/// <param name="dbcFilePath">DBC 矩阵文件的完整路径</param>
/// <returns>是否加载成功;该通道已加载过则直接返回 true</returns>
public virtual bool DBC文件(uint , string dbcFilePath)
{
if ( >= _maxChannels) return false;
@@ -251,6 +288,10 @@ namespace ZLGUSBCANFD
}
}
/// <summary>
/// 释放指定通道已加载的 DBC 资源,并清空该通道的报文数据库。
/// </summary>
/// <param name="通道号">通道索引 (0 起始)</param>
public virtual void DBC(uint )
{
if ( >= _maxChannels) return;
@@ -263,6 +304,13 @@ namespace ZLGUSBCANFD
_dbcHandles[] = 0;
_isDbcLoadedArray[] = false;
DBCParser.MsgDatabase[(int)].Clear();
// 同步清理该通道的最新解码报文缓存,避免残留旧信号值
foreach (var key in _latestDecodedMessages.Keys)
{
if (key. == )
_latestDecodedMessages.TryRemove(key, out _);
}
}
}
}
@@ -297,12 +345,18 @@ namespace ZLGUSBCANFD
{
IntPtr framePtr = IntPtr.Add(ptrCanBuffer, i * canStructSize);
// 触发原始报文事件(不依赖 DBC,自定义报文也能收到)
var rawData = (ZLGCAN.ZCAN_Receive_Data)Marshal.PtrToStructure(framePtr, typeof(ZLGCAN.ZCAN_Receive_Data))!;
OnRawMessageReceived?.Invoke(channelIndex, rawData.frame.can_id, rawData.frame.data, rawData.timestamp, false, rawData.frame.can_dlc);
// 只锁定当前通道的DBC句柄进行解码,高并发完全不卡顿
lock (_channelLocks[channelIndex])
{
if (_isDbcLoadedArray[channelIndex] && ZDBC.ZDBC_Decode(_dbcHandles[channelIndex], ptrDbcMsg, framePtr, 1, 0))
{
var msg = (ZDBC.DBCMessage)Marshal.PtrToStructure(ptrDbcMsg, typeof(ZDBC.DBCMessage));
// 缓存最新解码报文快照,供 获取报文信号值 查询
_latestDecodedMessages[(channelIndex, msg.nID)] = msg;
OnDbcMessageDecoded?.Invoke(channelIndex, msg);
}
}
@@ -319,11 +373,17 @@ namespace ZLGUSBCANFD
{
IntPtr framePtr = IntPtr.Add(ptrCanFDBuffer, i * canfdStructSize);
// 触发原始报文事件(不依赖 DBC,自定义报文也能收到)
var rawFdData = (ZLGCAN.ZCAN_ReceiveFD_Data)Marshal.PtrToStructure(framePtr, typeof(ZLGCAN.ZCAN_ReceiveFD_Data))!;
OnRawMessageReceived?.Invoke(channelIndex, rawFdData.frame.can_id, rawFdData.frame.data, rawFdData.timestamp, true, rawFdData.frame.len);
lock (_channelLocks[channelIndex])
{
if (_isDbcLoadedArray[channelIndex] && ZDBC.ZDBC_Decode(_dbcHandles[channelIndex], ptrDbcMsg, framePtr, 1, 1))
{
var msg = (ZDBC.DBCMessage)Marshal.PtrToStructure(ptrDbcMsg, typeof(ZDBC.DBCMessage));
// 缓存最新解码报文快照,供 获取报文信号值 查询
_latestDecodedMessages[(channelIndex, msg.nID)] = msg;
OnDbcMessageDecoded?.Invoke(channelIndex, msg);
}
}
@@ -358,6 +418,8 @@ namespace ZLGUSBCANFD
/// <param name="循环间隔毫秒">0 = 只发送一次;>0 = 按指定间隔循环发送(毫秒)</param>
/// <param name="是否使用CANFD">1 = CANFD0 = CAN</param>
/// <returns>是否成功启动/发送</returns>
[Browsable(false)]
public virtual bool DBC定义报文(
uint ,
uint ID,
@@ -426,6 +488,8 @@ namespace ZLGUSBCANFD
/// <summary>
/// 停止指定通道、指定帧 ID 的循环发送。
/// </summary>
/// <param name="通道号">通道索引 (0 起始)</param>
/// <param name="帧ID">要停止循环发送的报文帧 ID</param>
public virtual void (uint , uint ID)
{
if (_cyclicSenders.TryRemove((, ID), out var entry))
@@ -436,7 +500,7 @@ namespace ZLGUSBCANFD
}
/// <summary>
/// 停止所有 DBC 循环发送任务。
/// 停止所有 DBC 循环发送任务,并等待全部发送任务退出(最多 3 秒)
/// </summary>
public virtual void ()
{
@@ -470,6 +534,8 @@ namespace ZLGUSBCANFD
/// <summary>
/// 清除指定通道、指定帧 ID 的信号覆盖值,恢复为 DBC 初始值。
/// </summary>
/// <param name="通道号">通道索引 (0 起始)</param>
/// <param name="帧ID">要清除覆盖值的报文帧 ID</param>
public virtual void (uint , uint ID)
{
if (_signalOverrides.TryRemove((, ID), out var dict))
@@ -479,8 +545,9 @@ namespace ZLGUSBCANFD
}
/// <summary>
/// 清除指定通道所有帧的信号覆盖值。
/// 清除指定通道所有帧的信号覆盖值,全部恢复为 DBC 初始值
/// </summary>
/// <param name="通道号">通道索引 (0 起始)</param>
public virtual void (uint )
{
foreach (var key in _signalOverrides.Keys)
@@ -497,6 +564,7 @@ namespace ZLGUSBCANFD
/// <summary>
/// 单次发送 DBC 报文(调用方已持有通道锁)。
/// </summary>
[Browsable(false)]
private bool DBC定义报文单次(uint , uint ID, Dictionary<string, double> , int 使CANFD)
{
uint chDbcHandle = _dbcHandles[];
@@ -618,6 +686,7 @@ namespace ZLGUSBCANFD
/// <param name="信号名称">DBC 中定义的英文信号名称(不区分大小写)</param>
/// <param name="物理值">要写入的实际物理数值</param>
/// <param name="循环发送间隔毫秒">0 = 单次发送;>0 = 周期循环发送(单位毫秒)</param>
/// <param name="直接发送">true = 设置后立即发送;false = 仅记录到持久化覆盖表,等后续 发送报文 时生效</param>
/// <returns>操作是否成功</returns>
public virtual bool (uint , string IDStr, string , double , int = 0, bool = false)
{
@@ -653,6 +722,7 @@ namespace ZLGUSBCANFD
/// <param name="帧IDStr">DBC 中定义的帧 ID(可以是 "26"、"0x1A"、"1A"</param>
/// <param name="循环发送间隔毫秒">0 = 单次发送;>0 = 周期循环发送(毫秒)</param>
/// <param name="是否使用CANFD">1 = 以 CANFD 格式发送;0 = 经典 CAN 格式</param>
/// <returns>是否成功启动/发送</returns>
public virtual bool (uint , string IDStr, int = 0, int 使CANFD = 1)
{
if (!TryParseFrameId(IDStr, out uint ID))
@@ -671,6 +741,7 @@ namespace ZLGUSBCANFD
/// <summary>
/// 发送自定义报文(原始字节,帧 ID 支持 string 兼容模式)。
/// 循环发送间隔毫秒 = 0 时只发送一次;>0 时按指定间隔循环发送,可调用 停止循环发送 停止。
/// </summary>
/// <param name="通道号">CAN/CANFD 通道索引</param>
/// <param name="帧IDStr">帧 ID(可以是 "26"、"0x1A"、"1A");扩展帧会自动置位 0x80000000</param>
@@ -679,6 +750,8 @@ namespace ZLGUSBCANFD
/// <param name="是否是扩展帧">是否为 29 位扩展帧</param>
/// <param name="是否是CANFD">true = CANFDfalse = 经典 CAN</param>
/// <param name="开启波特率加速BRS">CANFD 是否开启波特率加速</param>
/// <param name="循环发送间隔毫秒">0 = 单次发送;>0 = 周期循环发送(毫秒)</param>
/// <returns>是否成功启动/发送</returns>
public virtual bool (
uint ,
string IDStr,
@@ -686,7 +759,8 @@ namespace ZLGUSBCANFD
byte dlcLength,
bool = false,
bool CANFD = true,
bool BRS = true)
bool BRS = true,
int = 0)
{
if (!TryParseFrameId(IDStr, out uint ID))
{
@@ -696,23 +770,88 @@ namespace ZLGUSBCANFD
if ( >= _maxChannels || _channelHandles[] == IntPtr.Zero) return false;
if ( == null) return false;
if ( < 0) throw new ArgumentOutOfRangeException(nameof(), "循环发送间隔必须大于或等于 0。");
// 扩展帧需要把最高位标志位置起来
uint canId = ID & 0x7FFFFFFF;
if () canId |= 0x80000000;
// 单次发送
if ( == 0)
{
return (, canId, , dlcLength, , CANFD, BRS);
}
// 循环发送:先停止同通道同帧 ID 的旧循环,再启动新循环
(, ID);
// 拷贝原始数据,避免外部修改影响循环发送
byte[] dataCopy = new byte[.Length];
Array.Copy(, dataCopy, .Length);
var cts = new CancellationTokenSource();
var sendTask = Task.Run(async () =>
{
while (!cts.Token.IsCancellationRequested)
{
if (_isClosing) break;
try
{
lock (_channelLocks[])
{
if (_channelHandles[] == IntPtr.Zero || _isClosing) break;
(, canId, dataCopy, dlcLength, , CANFD, BRS);
}
await Task.Delay(, cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.LoggerHelper.Error($"[ZLGCANFD] 循环发送自定义报文失败: {ex.Message}");
break;
}
}
if (_cyclicSenders.TryGetValue((, ID), out var current) && ReferenceEquals(current.Cts, cts))
{
_cyclicSenders.TryRemove((, ID), out _);
}
cts.Dispose();
}, cts.Token);
_cyclicSenders[(, ID)] = (cts, sendTask);
return true;
}
/// <summary>
/// 发送自定义报文的单次执行(内部方法,无循环逻辑)。
/// </summary>
[Browsable(false)]
private bool (
uint ,
uint canId,
byte[] ,
byte dlcLength,
bool ,
bool CANFD,
bool BRS)
{
lock (_channelLocks[])
{
if (CANFD)
{
// CANFD 有效 DLC 校验
byte validFdDlc = CANFD的Dlc(dlcLength);
ZLGCAN.canfd_frame fdFrame = new ZLGCAN.canfd_frame
{
can_id = canId,
len = validFdDlc,
flags = (byte)(( ? 0x01 : 0x00) | (BRS ? 0x02 : 0x00) | 0x20), // 0x20 本地回显
flags = (byte)(( ? 0x01 : 0x00) | (BRS ? 0x02 : 0x00) | 0x20),
data = new byte[64]
};
Array.Copy(, 0, fdFrame.data, 0, Math.Min(.Length, 64));
@@ -732,7 +871,7 @@ namespace ZLGUSBCANFD
{
can_id = canId,
can_dlc = dlcLength > 8 ? (byte)8 : dlcLength,
__pad = 0x20, // 发送回显
__pad = 0x20,
data = new byte[8]
};
Array.Copy(, 0, standardFrame.data, 0, Math.Min(.Length, 8));
@@ -899,11 +1038,73 @@ namespace ZLGUSBCANFD
#endregion
#region 5.
#region 4.2
/// <summary>
/// 计算物理值也需要传入对应通道,使用通道专属的DBC句柄进行计算
/// 获取指定通道最新一帧 DBC 报文中某个信号的物理值(帧 ID 支持 string 兼容模式)。
/// <para>
/// 返回值为最近一次接收并解码成功的报文快照:物理值 = 原始值 × 因子 + 偏移,
/// 与监控界面广播的数值口径一致。若报文尚未收到、DBC 未加载、报文或信号名称不存在,
/// 将记录错误日志并返回 0。
/// </para>
/// </summary>
/// <param name="通道号">CAN/CANFD 通道索引</param>
/// <param name="帧IDStr">对应报文的帧 ID(可以是 "26"、"0x1A"、"1A"</param>
/// <param name="信号名称">DBC 中定义的英文信号名称(不区分大小写)</param>
/// <returns>信号物理值;失败时返回 0</returns>
public virtual double (uint , string IDStr, string )
{
if (!TryParseFrameId(IDStr, out uint ID))
{
LoggerHelper.Error($"[ZLGCANFD] 获取报文信号值失败: 无法解析的帧 ID '{帧IDStr}'");
return 0;
}
if ( >= _maxChannels)
{
LoggerHelper.Error($"[ZLGCANFD] 获取报文信号值失败: 通道号 {通道号} 越界(有效范围 0~{_maxChannels - 1}");
return 0;
}
if (!_isDbcLoadedArray[])
{
LoggerHelper.Error($"[ZLGCANFD] 获取报文信号值失败: 通道 {通道号} 尚未加载 DBC 文件");
return 0;
}
if (string.IsNullOrWhiteSpace())
{
LoggerHelper.Error("[ZLGCANFD] 获取报文信号值失败: 信号名称为空");
return 0;
}
// 查找该通道该帧 ID 最近一次解码成功的报文快照
if (!_latestDecodedMessages.TryGetValue((, ID), out var msg))
{
LoggerHelper.Error($"[ZLGCANFD] 获取报文信号值失败: 通道 {通道号} 未收到帧 ID 0x{帧ID:X} 的报文(尚未接收或 DBC 中无此报文)");
return 0;
}
// 在报文信号集合中查找目标信号(不区分大小写,与 设置报文 的信号匹配口径一致)
for (int i = 0; i < msg.nSignalCount; i++)
{
var signal = msg.vSignals[i];
string name = Encoding.Default.GetString(signal.strName).TrimEnd('\0');
if (string.Equals(name, , StringComparison.OrdinalIgnoreCase))
{
// 物理值 = 原始值 × 因子 + 偏移(与 CANSignalBroadcaster 广播公式一致)
return signal.nRawvalue * signal.nFactor + signal.nOffset;
}
}
LoggerHelper.Error($"[ZLGCANFD] 获取报文信号值失败: 帧 ID 0x{帧ID:X} 的报文中不存在信号 '{信号名称}'");
return 0;
}
#endregion
#region 5.
/// <summary>
/// 智能解析帧 ID 字符串,兼容 10 进制、16 进制(如 "0x1A", "1A", "26"
/// </summary>
+1
View File
@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>