Compare commits

...
12 Commits
Author SHA1 Message Date
“hsc” 9cb139b208 CAN修复 2026-10-26 10:46:44 +08:00
“hsc” e877dc9a90 示波器图片展示 2026-10-23 10:13:31 +08:00
“hsc” d9052861f5 synthesis模式bug修改 2026-10-22 14:36:21 +08:00
“hsc” 6bbca727ca 源适应性修改 2026-10-21 16:44:15 +08:00
“hsc” 12b532382d 示波器加入源适应性界面 2026-10-20 15:33:36 +08:00
“hsc” 981bc1e558 添加xcpcore,添加加载dbc至单一通道 2026-10-15 14:05:18 +08:00
“hsc” a2a62a0327 最初阶段 2026-10-14 16:45:58 +08:00
“hsc” 59d0c24deb io映射修改,低压电源命令修改 2026-10-14 16:27:38 +08:00
“hsc” cd52ca64af it67200改visa 2026-10-13 14:36:14 +08:00
“hsc” 691fb62385 visa 2026-10-13 13:41:05 +08:00
“hsc” d84f2a874b 源适应性界面优化 2026-10-12 08:49:04 +08:00
“hsc” 8dd08fcc7a chroma synthesis模式 2026-10-09 09:23:32 +08:00
35 changed files with 6776 additions and 1115 deletions
+13 -1
View File
@@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TSMasterCAN", "TSMasterCAN\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerAdaptabilityModule", "PowerAdaptabilityModule\PowerAdaptabilityModule.csproj", "{CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerAdaptabilityModule", "PowerAdaptabilityModule\PowerAdaptabilityModule.csproj", "{CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XCPCore", "XCPCore\XCPCore\XCPCore.csproj", "{9D2D7AC1-60D3-44A5-ADB9-46E8FAE3A1D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TSMasterXCPModule", "TSMasterXCPModule\TSMasterXCPModule.csproj", "{1296318F-BD2F-4650-B16E-656BA04E80C0}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -137,6 +141,14 @@ Global
{CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89}.Debug|Any CPU.Build.0 = Debug|Any CPU {CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89}.Release|Any CPU.ActiveCfg = Release|Any CPU {CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89}.Release|Any CPU.Build.0 = Release|Any CPU {CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89}.Release|Any CPU.Build.0 = Release|Any CPU
{9D2D7AC1-60D3-44A5-ADB9-46E8FAE3A1D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9D2D7AC1-60D3-44A5-ADB9-46E8FAE3A1D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9D2D7AC1-60D3-44A5-ADB9-46E8FAE3A1D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9D2D7AC1-60D3-44A5-ADB9-46E8FAE3A1D6}.Release|Any CPU.Build.0 = Release|Any CPU
{1296318F-BD2F-4650-B16E-656BA04E80C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1296318F-BD2F-4650-B16E-656BA04E80C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1296318F-BD2F-4650-B16E-656BA04E80C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1296318F-BD2F-4650-B16E-656BA04E80C0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -150,8 +162,8 @@ Global
{850A5BE8-0BC1-4D6A-8351-DD812C9C4427} = {1E92D601-68FC-45F6-8251-BD0F39226E5B} {850A5BE8-0BC1-4D6A-8351-DD812C9C4427} = {1E92D601-68FC-45F6-8251-BD0F39226E5B}
{170AD4C1-189D-4FBE-B10D-2A4304527834} = {1E92D601-68FC-45F6-8251-BD0F39226E5B} {170AD4C1-189D-4FBE-B10D-2A4304527834} = {1E92D601-68FC-45F6-8251-BD0F39226E5B}
{AF2533DD-599D-49D7-942B-B0C26534D15F} = {1E92D601-68FC-45F6-8251-BD0F39226E5B} {AF2533DD-599D-49D7-942B-B0C26534D15F} = {1E92D601-68FC-45F6-8251-BD0F39226E5B}
{84006890-9288-44C4-9D17-FA88F3FDECFA} = {1E92D601-68FC-45F6-8251-BD0F39226E5B}
{8923122F-F7D3-4AE7-9E86-9E78F1296A11} = {1E92D601-68FC-45F6-8251-BD0F39226E5B} {8923122F-F7D3-4AE7-9E86-9E78F1296A11} = {1E92D601-68FC-45F6-8251-BD0F39226E5B}
{CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89} = {1E92D601-68FC-45F6-8251-BD0F39226E5B} {CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89} = {1E92D601-68FC-45F6-8251-BD0F39226E5B}
{1296318F-BD2F-4650-B16E-656BA04E80C0} = {1E92D601-68FC-45F6-8251-BD0F39226E5B}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+1
View File
@@ -33,6 +33,7 @@
<ProjectReference Include="..\SettingModule\SettingModule.csproj" /> <ProjectReference Include="..\SettingModule\SettingModule.csproj" />
<ProjectReference Include="..\TestingModule\TestingModule.csproj" /> <ProjectReference Include="..\TestingModule\TestingModule.csproj" />
<ProjectReference Include="..\TSMasterCAN\TSMasterCAN.csproj" /> <ProjectReference Include="..\TSMasterCAN\TSMasterCAN.csproj" />
<ProjectReference Include="..\TSMasterXCPModule\TSMasterXCPModule.csproj" />
<ProjectReference Include="..\UIShare\UIShare.csproj" /> <ProjectReference Include="..\UIShare\UIShare.csproj" />
<ProjectReference Include="..\UpdateInfoMoudle\UpdateInfoMoudle.csproj" /> <ProjectReference Include="..\UpdateInfoMoudle\UpdateInfoMoudle.csproj" />
</ItemGroup> </ItemGroup>
+21 -16
View File
@@ -49,11 +49,15 @@ namespace ACP
private void OnDispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e) private void OnDispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{ {
LoggerHelper.Error(e.Exception.Message, e.Exception.StackTrace); LoggerHelper.Error(e.Exception.Message, e.Exception.StackTrace);
// 必须标记已处理,否则异常在日志后继续传播,进程照样闪退
e.Handled = true;
} }
private void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) private void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
{ {
LoggerHelper.Error(e.Exception.Message, e.Exception.StackTrace); LoggerHelper.Error(e.Exception.Message, e.Exception.StackTrace);
// 必须标记已观察,否则终结器线程会重新抛出该异常并导致进程闪退
e.SetObserved();
} }
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
@@ -134,21 +138,22 @@ namespace ACP
} }
protected override void OnStartup(StartupEventArgs e) protected override void OnStartup(StartupEventArgs e)
{ {
if (System.Diagnostics.Debugger.IsAttached) base.OnStartup(e);
{ //if (System.Diagnostics.Debugger.IsAttached)
base.OnStartup(e); //{
return; // base.OnStartup(e);
} // return;
string myMachineCode = MachineCodeHelper.GetDeviceMachineCode(); //}
if (MachineCodeHelper.VerifyMachineCode(myMachineCode)) //string myMachineCode = MachineCodeHelper.GetDeviceMachineCode();
{ //if (MachineCodeHelper.VerifyMachineCode(myMachineCode))
base.OnStartup(e); //{
} // base.OnStartup(e);
else //}
{ //else
MessageBox.Show("机器码不匹配!"); //{
return; // MessageBox.Show("机器码不匹配!");
} // return;
//}
} }
protected override void RegisterRequiredTypes(IContainerRegistry containerRegistry) protected override void RegisterRequiredTypes(IContainerRegistry containerRegistry)
@@ -175,7 +180,7 @@ namespace ACP
//注册弹窗 //注册弹窗
containerRegistry.RegisterDialog<MessageBoxView, MessageBoxViewModel>("MessageBox"); containerRegistry.RegisterDialog<MessageBoxView, MessageBoxViewModel>("MessageBox");
// 注册通知管理器 // 注册通知管理器
INotificationManager NotificationManager = new NotificationManager(); INotificationManager NotificationManager = new NotificationManager();
containerRegistry.RegisterInstance<INotificationManager>(NotificationManager); containerRegistry.RegisterInstance<INotificationManager>(NotificationManager);
// 注册仓储 // 注册仓储
containerRegistry.RegisterScoped(typeof(SqlSugarRepository<>)); containerRegistry.RegisterScoped(typeof(SqlSugarRepository<>));
+4
View File
@@ -906,6 +906,10 @@ namespace ACP.ViewModels
if (_globalInfo.CurrentScope != "default") return; if (_globalInfo.CurrentScope != "default") return;
_regionManager.RequestNavigate("ShellViewManager", "PowerAdaptabilityView"); _regionManager.RequestNavigate("ShellViewManager", "PowerAdaptabilityView");
break; break;
case "TSMaster XCP 测试":
if (_globalInfo.CurrentScope != "default") return;
_regionManager.RequestNavigate("ShellViewManager", "TSMasterXCPView");
break;
} }
} }
+5
View File
@@ -103,6 +103,11 @@
CommandParameter="{Binding Content, RelativeSource={RelativeSource Self}}" CommandParameter="{Binding Content, RelativeSource={RelativeSource Self}}"
Style="{StaticResource MaterialDesignFlatButton}" Style="{StaticResource MaterialDesignFlatButton}"
Margin="8" /> Margin="8" />
<Button Content="TSMaster XCP 测试"
Command="{Binding NavigateCommand}"
CommandParameter="{Binding Content, RelativeSource={RelativeSource Self}}"
Style="{StaticResource MaterialDesignFlatButton}"
Margin="8" />
</StackPanel> </StackPanel>
</materialDesign:DrawerHost.LeftDrawerContent> </materialDesign:DrawerHost.LeftDrawerContent>
<Grid> <Grid>
+59 -8
View File
@@ -315,9 +315,10 @@ namespace CANModule.ViewModels
} }
catch (Exception ex) catch (Exception ex)
{ {
_dialogService.ShowDialog("MessageDialog", new DialogParameters _dialogService.ShowDialog("MessageBox", new DialogParameters
{ {
{ "message", $"报文转换失败:{ex.Message}" } { "Message", $"报文转换失败:{ex.Message}" },
{ "ShowOk", true },
}); });
return; return;
} }
@@ -343,16 +344,43 @@ namespace CANModule.ViewModels
private void SetAndSendMessage() private void SetAndSendMessage()
{ {
if (int.TryParse(CycleSendInterval, out int sendPeriod) && double.TryParse(SignalValue, out double SValue) && byte.TryParse(MessageSendChannel, out byte c)) if (!int.TryParse(CycleSendInterval, out int sendPeriod) ||
!double.TryParse(SignalValue, out double SValue) ||
!byte.TryParse(MessageSendChannel, out byte c))
{ {
channel = c; ShowMessage("发送参数无效:请检查发送周期、信号值和发送通道。");
var message = SelectedMessage.Split(']')[1]; return;
var re = CAN.SetSignalValue(channel, message, SelectedSignal, SValue, IsDirectlySend, sendPeriod);
} }
else channel = c;
{ var parts = SelectedMessage.Split(']');
var message = parts.Length > 1 ? parts[1] : SelectedMessage;
try
{
var re = CAN.SetSignalValue(channel, message, SelectedSignal, SValue, IsDirectlySend, sendPeriod);
if (re != 0)
{
// -1 表示托管缓存里没有这条报文:DBC 没加载到该通道,或加载通道与发送通道不一致
ShowMessage(re == -1
? $"通道 {channel} 的 DBC 缓存中没有报文 [{message}],请先加载 DBC 并确认加载通道与发送通道一致。"
: $"发送报文失败,返回代码 {re}:{CAN.GetErrorDescription(re)}");
}
} }
catch (Exception ex)
{
// 93(message not found in database) 等原生错误在这里提示,避免冒泡到全局异常处理器
ShowMessage(ex.Message);
}
}
/// <summary>统一的提示弹窗</summary>
private void ShowMessage(string message)
{
_dialogService.ShowDialog("MessageBox", new DialogParameters
{
{ "Message", message },
{ "ShowOk", true },
});
} }
private void ReleaseTSMaster() private void ReleaseTSMaster()
@@ -363,6 +391,29 @@ namespace CANModule.ViewModels
private void InitTSMaster() private void InitTSMaster()
{ {
var re = CAN.Init("ACP"); var re = CAN.Init("ACP");
if (re != 0)
{
ShowMessage($"TSMaster 初始化失败,返回代码 {re}:{CAN.GetErrorDescription(re)}");
return;
}
// 驱动被释放后原生 DBC 数据库为空、托管缓存也已被清空,
// 这里按保存的自动加载列表把 DBC 重新下发,否则发送会得到 93
if (CAN.NeedReloadDbc) ReloadAutoDbc();
}
/// <summary>重新下发配置中保存的 DBC(驱动重启后调用)</summary>
private void ReloadAutoDbc()
{
foreach (var item in DBCAutoLoadList)
{
if (item.DBCChannel < 0 || item.DBCChannel >= 4) continue;
if (string.IsNullOrWhiteSpace(item.DBCFilePath) || !File.Exists(item.DBCFilePath)) continue;
var re = CAN.LoadDBC(item.DBCFilePath, new[] { item.DBCChannel }, out _);
if (re != 0)
ShowMessage($"DBC 重新加载失败(通道 {item.DBCChannel}):{item.DBCFilePath},返回代码 {re}");
}
} }
private void UpLoadDBC() private void UpLoadDBC()
{ {
+53 -1
View File
@@ -195,13 +195,21 @@ namespace DeviceCommand.Base
} }
} }
/// <summary>
/// 读取定长原始二进制字节(用于 IT6720 等自定义二进制帧协议)。
/// <para>VISA 会话在 <see cref="ResetConnection"/> 中默认启用了以 <c>\n</c>(0x0A) 为终止符的文本读取;
/// 而二进制帧的数据区可能恰好包含 0x0A 字节,若不禁用终止符,<c>RawIO.Read</c> 会在该字节处提前结束,
/// 导致返回长度不足、帧校验失败。故本方法读取期间临时关闭终止符,读完再由 finally 恢复。</para>
/// <para>单次 <c>RawIO.Read(count)</c> 只保证返回「至多 count」字节,可能因分包而少于请求长度;
/// 这里参照 <see cref="ReadRawUntilEnd"/> 的做法循环补足,直到读满 length 或发生超时/END/流终止。</para>
/// </summary>
public async Task<byte[]> ReadAsync(int length, CancellationToken ct = default) public async Task<byte[]> ReadAsync(int length, CancellationToken ct = default)
{ {
await _commLock.WaitAsync(ct); await _commLock.WaitAsync(ct);
try try
{ {
EnsureConnected(); EnsureConnected();
return await Task.Run(() => _session!.RawIO.Read(length), ct); return await Task.Run(() => ReadExactBytes(length), ct);
} }
finally finally
{ {
@@ -209,6 +217,50 @@ namespace DeviceCommand.Base
} }
} }
/// <summary>
/// 同步读取定长二进制字节:关闭终止符后循环补足,直到读满 <paramref name="length"/> 字节;
/// 超时/END/流终止导致读不满时按实到长度截断返回(由上层按帧长校验判定成功与否)。
/// </summary>
private byte[] ReadExactBytes(int length)
{
if (length <= 0) return Array.Empty<byte>();
bool prevTermEnabled = _session!.TerminationCharacterEnabled;
// 二进制帧数据可能含 0x0A,读取全程禁用终止符,避免被误判为帧结束而截断
_session!.TerminationCharacterEnabled = false;
try
{
var buffer = new byte[length];
int offset = 0;
while (offset < length)
{
byte[] chunk;
try
{
chunk = _session!.RawIO.Read(length - offset);
}
catch (Exception) when (offset > 0)
{
// 已收到部分数据后再超时/异常:返回已收字节,交由上层按帧长校验处理
break;
}
if (chunk == null || chunk.Length == 0) break;
Buffer.BlockCopy(chunk, 0, buffer, offset, chunk.Length);
offset += chunk.Length;
}
if (offset == length) return buffer;
// 实际读到的字节少于请求长度:按实到长度截断返回,避免尾部填充 0
var trimmed = new byte[offset];
Buffer.BlockCopy(buffer, 0, trimmed, 0, offset);
return trimmed;
}
finally
{
_session!.TerminationCharacterEnabled = prevTermEnabled;
}
}
public async Task<string> ReadAsync(string delimiter = "\n", CancellationToken ct = default) public async Task<string> ReadAsync(string delimiter = "\n", CancellationToken ct = default)
{ {
await _commLock.WaitAsync(ct); await _commLock.WaitAsync(ct);
File diff suppressed because it is too large Load Diff
+201 -18
View File
@@ -12,6 +12,8 @@ namespace DeviceCommand.Devices
{ {
/// <summary> /// <summary>
/// 交流源 一拖三 (Chroma 61800系列) /// 交流源 一拖三 (Chroma 61800系列)
/// <para>SCPI 命令与参数范围按新手册 (61809/61812/61815, 新chromascpi.pdf) 校准:
/// 波形参数 SINE/SQUA/TRIAN/CSIN/DST01~30/USR01~30,Vac 0.0~350.0V,Vdc -495.0~495.0V,FREQ 30.00~100.00Hz</para>
/// </summary> /// </summary>
[ACPCommand] [ACPCommand]
public class Chroma61800 : Tcp public class Chroma61800 : Tcp
@@ -86,15 +88,24 @@ namespace DeviceCommand.Devices
return await WriteReadAsync($"*IDN?{ScpiDelimiter}", ScpiDelimiter, ct); return await WriteReadAsync($"*IDN?{ScpiDelimiter}", ScpiDelimiter, ct);
} }
/// <summary> /// <summary>
/// 命令流延时 (*D) /// 退出远程状态、回到本地 (SYST:LOC,即 SYSTem:LOCal)
/// <para>在 SCPI 命令流中插入一段延时(单位: ms),常用于 LIST 参数下发完成后、切换 OUTP:MODE 之前等待仪器就绪</para> /// <para>设定后仪器回到 LOCAL 状态,前面板恢复可用。手册注明本命令僅可用於 RS-232 控制之下,
/// 经 Ethernet/USB 发送时仪器可能不响应</para>
/// </summary> /// </summary>
/// <param name="milliseconds">延时时长 (ms)</param> public virtual async Task 退出远程Async(CancellationToken ct = default)
public virtual async Task 延时Async(double milliseconds, CancellationToken ct = default)
{ {
string valStr = milliseconds.ToString("0.###", CultureInfo.InvariantCulture); await SendAsync($"SYST:LOC{ScpiDelimiter}", ct);
await SendAsync($"*D {valStr}{ScpiDelimiter}", ct); }
/// <summary>
/// 进入远程状态 (SYST:REM,即 SYSTem:REMote)
/// <para>设定后除 LOCAL/REMOTE 键外前面板其他键皆无效。手册注明本命令僅可用於 RS-232 控制之下</para>
/// </summary>
public virtual async Task 进入远程Async(CancellationToken ct = default)
{
await SendAsync($"SYST:REM{ScpiDelimiter}", ct);
} }
#endregion #endregion
@@ -136,7 +147,7 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 设置交流电压 (VOLT:AC) /// 设置交流电压 (VOLT:AC)
/// </summary> /// </summary>
/// <param name="voltage">电压值 (0 ~ 300V 或高压选配范围)</param> /// <param name="voltage">电压值 (0.0 ~ 350.0V)</param>
public virtual async Task 设置交流电压Async(double voltage, CancellationToken ct = default) public virtual async Task 设置交流电压Async(double voltage, CancellationToken ct = default)
{ {
string valStr = voltage.ToString("0.###", CultureInfo.InvariantCulture); string valStr = voltage.ToString("0.###", CultureInfo.InvariantCulture);
@@ -146,6 +157,7 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 设置直流电压 (VOLT:DC) /// 设置直流电压 (VOLT:DC)
/// </summary> /// </summary>
/// <param name="voltage">电压值 (-495.0 ~ 495.0V)</param>
public virtual async Task 设置直流电压Async(double voltage, CancellationToken ct = default) public virtual async Task 设置直流电压Async(double voltage, CancellationToken ct = default)
{ {
string valStr = voltage.ToString("0.###", CultureInfo.InvariantCulture); string valStr = voltage.ToString("0.###", CultureInfo.InvariantCulture);
@@ -155,7 +167,7 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 设置输出频率 (FREQ) /// 设置输出频率 (FREQ)
/// </summary> /// </summary>
/// <param name="frequency">频率值 (Hz)</param> /// <param name="frequency">频率值 (30.00 ~ 100.00 Hz)</param>
public virtual async Task 设置频率Async(double frequency, CancellationToken ct = default) public virtual async Task 设置频率Async(double frequency, CancellationToken ct = default)
{ {
string valStr = frequency.ToString("0.###", CultureInfo.InvariantCulture); string valStr = frequency.ToString("0.###", CultureInfo.InvariantCulture);
@@ -163,8 +175,11 @@ namespace DeviceCommand.Devices
} }
/// <summary> /// <summary>
/// 设置波形选择 (FUNC:SHAP:A SINE / SQUA / CSIN 等) /// 设置波形 A 组形状 (FUNC:SHAP:A)
/// <para>冒号后的 A 是波形组别(仪器有 A/B 两组波形寄存器),不是波形形状;
/// 三角波拼写为 TRIAN(TRIA/TRI 均非法,仪器只接受精确的 TRIAN)</para>
/// </summary> /// </summary>
/// <param name="shape">SINE | SQUA | TRIAN | CSIN | DST01~DST30(内建)| USR01~USR30(用户自定义)</param>
public virtual async Task 设置波形Async(string shape, CancellationToken ct = default) public virtual async Task 设置波形Async(string shape, CancellationToken ct = default)
{ {
await SendAsync($"FUNC:SHAP:A {shape}{ScpiDelimiter}", ct); await SendAsync($"FUNC:SHAP:A {shape}{ScpiDelimiter}", ct);
@@ -175,7 +190,7 @@ namespace DeviceCommand.Devices
#region 4. 相位与多相模式控制 (INST & PHASE 子系统) #region 4. 相位与多相模式控制 (INST & PHASE 子系统)
/// <summary> /// <summary>
/// 设置单/三相工作模式 (INST:PHAS THREE / SINGLE) /// 设置单/三相工作模式 (INST:PHAS THREE / SINGLE / SPLIT)
/// </summary> /// </summary>
/// <param name="isThreePhase">true: 三相模式, false: 单相模式</param> /// <param name="isThreePhase">true: 三相模式, false: 单相模式</param>
public virtual async Task 设置单三相模式Async(bool isThreePhase, CancellationToken ct = default) public virtual async Task 设置单三相模式Async(bool isThreePhase, CancellationToken ct = default)
@@ -331,6 +346,16 @@ namespace DeviceCommand.Devices
await SendAsync($"SOUR:LIST:COUN {count}{ScpiDelimiter}", ct); await SendAsync($"SOUR:LIST:COUN {count}{ScpiDelimiter}", ct);
} }
/// <summary>
/// 查询当前相位上下文的列表执行次数 (SOUR:LIST:COUN?),用于下发后回读校验
/// </summary>
/// <returns>执行次数 (0 = 无限循环);解析失败返回 -1</returns>
public virtual async Task<int> 查询列表执行次数Async(CancellationToken ct = default)
{
string resp = await WriteReadAsync($"SOUR:LIST:COUN?{ScpiDelimiter}", ScpiDelimiter, ct);
return int.TryParse(resp.Trim(), out int val) ? val : -1;
}
/// <summary> /// <summary>
/// 设定列表点的静止时间顺序 (SOUR:LIST:DWEL) /// 设定列表点的静止时间顺序 (SOUR:LIST:DWEL)
/// </summary> /// </summary>
@@ -344,7 +369,7 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 设定波形缓冲区列表点数的顺序 (SOUR:LIST:SHAP) /// 设定波形缓冲区列表点数的顺序 (SOUR:LIST:SHAP)
/// </summary> /// </summary>
/// <param name="shapes">波形缓冲区顺序,每个元素为 A 或 B</param> /// <param name="shapes">各点波形: A | B | SINE | SQUA | TRIAN | CSIN | DST01~DST30 | USR01~USR30(可直接写形状名,也可引用 A/B 波形组)</param>
public virtual async Task 设置列表波形顺序Async(string[] shapes, CancellationToken ct = default) public virtual async Task 设置列表波形顺序Async(string[] shapes, CancellationToken ct = default)
{ {
string valStr = string.Join(",", shapes); string valStr = string.Join(",", shapes);
@@ -360,10 +385,28 @@ namespace DeviceCommand.Devices
await SendAsync($"SOUR:LIST:BASE {timeBase}{ScpiDelimiter}", ct); await SendAsync($"SOUR:LIST:BASE {timeBase}{ScpiDelimiter}", ct);
} }
/// <summary>
/// 设定列表功能的相位连续功能 (SOUR:LIST:PHAS:CONT)
/// </summary>
/// <param name="state">DISABLE | ENABLE</param>
public virtual async Task 设置列表相位连续Async(string state, CancellationToken ct = default)
{
await SendAsync($"SOUR:LIST:PHAS:CONT {state}{ScpiDelimiter}", ct);
}
/// <summary>
/// 查询列表功能的相位连续功能 (SOUR:LIST:PHAS:CONT?),返回 DISABLE 或 ENABLE
/// </summary>
public virtual async Task<string> 查询列表相位连续Async(CancellationToken ct = default)
{
string resp = await WriteReadAsync($"SOUR:LIST:PHAS:CONT?{ScpiDelimiter}", ScpiDelimiter, ct);
return resp?.Trim() ?? string.Empty;
}
/// <summary> /// <summary>
/// 设定 AC 起始电压列表点数的顺序 (SOUR:LIST:VOLT:AC:STAR) /// 设定 AC 起始电压列表点数的顺序 (SOUR:LIST:VOLT:AC:STAR)
/// </summary> /// </summary>
/// <param name="voltages">AC 起始电压序列 (单位: V,每个值 0.0 ~ 300.0)</param> /// <param name="voltages">AC 起始电压序列 (单位: V,每个值 0.0 ~ 350.0)</param>
public virtual async Task 设置列表AC起始电压顺序Async(double[] voltages, CancellationToken ct = default) public virtual async Task 设置列表AC起始电压顺序Async(double[] voltages, CancellationToken ct = default)
{ {
string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture))); string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture)));
@@ -373,7 +416,7 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 设定 AC 结束电压列表点数的顺序 (SOUR:LIST:VOLT:AC:END) /// 设定 AC 结束电压列表点数的顺序 (SOUR:LIST:VOLT:AC:END)
/// </summary> /// </summary>
/// <param name="voltages">AC 结束电压序列 (单位: V,每个值 0.0 ~ 300.0)</param> /// <param name="voltages">AC 结束电压序列 (单位: V,每个值 0.0 ~ 350.0)</param>
public virtual async Task 设置列表AC结束电压顺序Async(double[] voltages, CancellationToken ct = default) public virtual async Task 设置列表AC结束电压顺序Async(double[] voltages, CancellationToken ct = default)
{ {
string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture))); string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture)));
@@ -383,7 +426,7 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 设定 DC 起始电压列表点数的顺序 (SOUR:LIST:VOLT:DC:STAR) /// 设定 DC 起始电压列表点数的顺序 (SOUR:LIST:VOLT:DC:STAR)
/// </summary> /// </summary>
/// <param name="voltages">DC 起始电压序列 (单位: V,每个值 -424.2 ~ 414.2)</param> /// <param name="voltages">DC 起始电压序列 (单位: V,每个值 -495.0 ~ 495.0)</param>
public virtual async Task 设置列表DC起始电压顺序Async(double[] voltages, CancellationToken ct = default) public virtual async Task 设置列表DC起始电压顺序Async(double[] voltages, CancellationToken ct = default)
{ {
string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture))); string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture)));
@@ -393,7 +436,7 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 设定 DC 结束电压列表点数的顺序 (SOUR:LIST:VOLT:DC:END) /// 设定 DC 结束电压列表点数的顺序 (SOUR:LIST:VOLT:DC:END)
/// </summary> /// </summary>
/// <param name="voltages">DC 结束电压序列 (单位: V,每个值 -424.2 ~ 414.2)</param> /// <param name="voltages">DC 结束电压序列 (单位: V,每个值 -495.0 ~ 495.0)</param>
public virtual async Task 设置列表DC结束电压顺序Async(double[] voltages, CancellationToken ct = default) public virtual async Task 设置列表DC结束电压顺序Async(double[] voltages, CancellationToken ct = default)
{ {
string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture))); string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture)));
@@ -403,7 +446,7 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 设定起始频率列表点数的顺序 (SOUR:LIST:FREQ:STAR) /// 设定起始频率列表点数的顺序 (SOUR:LIST:FREQ:STAR)
/// </summary> /// </summary>
/// <param name="frequencies">起始频率序列 (单位: Hz,每个值 15.00 ~ 100.00)</param> /// <param name="frequencies">起始频率序列 (单位: Hz,每个值 30.00 ~ 100.00)</param>
public virtual async Task 设置列表起始频率顺序Async(double[] frequencies, CancellationToken ct = default) public virtual async Task 设置列表起始频率顺序Async(double[] frequencies, CancellationToken ct = default)
{ {
string valStr = string.Join(",", Array.ConvertAll(frequencies, v => v.ToString("0.##", CultureInfo.InvariantCulture))); string valStr = string.Join(",", Array.ConvertAll(frequencies, v => v.ToString("0.##", CultureInfo.InvariantCulture)));
@@ -413,7 +456,7 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 设定结束频率列表点数的顺序 (SOUR:LIST:FREQ:END) /// 设定结束频率列表点数的顺序 (SOUR:LIST:FREQ:END)
/// </summary> /// </summary>
/// <param name="frequencies">结束频率序列 (单位: Hz,每个值 15.00 ~ 100.00)</param> /// <param name="frequencies">结束频率序列 (单位: Hz,每个值 30.00 ~ 100.00)</param>
public virtual async Task 设置列表结束频率顺序Async(double[] frequencies, CancellationToken ct = default) public virtual async Task 设置列表结束频率顺序Async(double[] frequencies, CancellationToken ct = default)
{ {
string valStr = string.Join(",", Array.ConvertAll(frequencies, v => v.ToString("0.##", CultureInfo.InvariantCulture))); string valStr = string.Join(",", Array.ConvertAll(frequencies, v => v.ToString("0.##", CultureInfo.InvariantCulture)));
@@ -509,7 +552,8 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// 开启/关闭谐波量测 (SENSe:HARMonic) /// 开启/关闭谐波量测 (SENSe:HARMonic)
/// <para>每次新的 Fetch/Measure 前必须先 ON,约需 3 秒得到结果; /// <para>每次新的 Fetch
/// ./Measure 前必须先 ON,约需 3 秒得到结果;
/// 若要量测其他数据,须先设回 OFF。分相量测配合 INST:COUP NONE + INST:NSEL 逐相进行</para> /// 若要量测其他数据,须先设回 OFF。分相量测配合 INST:COUP NONE + INST:NSEL 逐相进行</para>
/// </summary> /// </summary>
/// <param name="isOn">true: 开启谐波量测(ON); false: 关闭(OFF)</param> /// <param name="isOn">true: 开启谐波量测(ON); false: 关闭(OFF)</param>
@@ -556,5 +600,144 @@ namespace DeviceCommand.Devices
#endregion #endregion
#region 8. SYNTHESIS 波形合成子系统 (SYNThesis 子系统 · 自定义谐波波形输出)
/// <summary>
/// 设定每个谐波阶的数据格式 (SOUR:SYNT:COMP)
/// <para>VALUE: 绝对值; PERCENT: 基频电压的百分率。后缀 1/2/3 对应三段可编程波形</para>
/// </summary>
/// <param name="compose">数据格式: VALUE1 | VALUE2 | VALUE3 | PERCENT1 | PERCENT2 | PERCENT3</param>
public virtual async Task 设置合成数据格式Async(string compose, CancellationToken ct = default)
{
await SendAsync($"SOUR:SYNT:COMP {compose}{ScpiDelimiter}", ct);
}
/// <summary>
/// 设定每个谐波阶的振幅 (SOUR:SYNT:AMPL)
/// <para>最大位阶数为 50;各阶取值上限随阶次递减(2~10 阶 0~90.0,11~20 阶 0~60.0,
/// 21~30 阶 0~30.0,31~40 阶 0~30.0,41~50 阶 0~15.0;PERCENT 格式对应减半)</para>
/// </summary>
/// <param name="amplitudes">各次谐波振幅序列(按阶次升序,逗号下发)</param>
public virtual async Task 设置合成谐波振幅Async(double[] amplitudes, CancellationToken ct = default)
{
string valStr = string.Join(",", Array.ConvertAll(amplitudes, v => v.ToString("0.###", CultureInfo.InvariantCulture)));
await SendAsync($"SOUR:SYNT:AMPL {valStr}{ScpiDelimiter}", ct);
}
/// <summary>
/// 设定每个谐波阶的相位角 (SOUR:SYNT:PHAS)
/// </summary>
/// <param name="phases">各次谐波相位角序列(单位: °,每个值 0.0 ~ 359.9)</param>
public virtual async Task 设置合成谐波相位Async(double[] phases, CancellationToken ct = default)
{
string valStr = string.Join(",", Array.ConvertAll(phases, v => v.ToString("0.#", CultureInfo.InvariantCulture)));
await SendAsync($"SOUR:SYNT:PHAS {valStr}{ScpiDelimiter}", ct);
}
/// <summary>
/// 设定 SYNTHESIS 模式的基波交流电压 (SOUR:SYNT:FUND)
/// </summary>
/// <param name="voltage">基波交流电压 (单位: V,0.0 ~ 350.0)</param>
public virtual async Task 设置合成基波电压Async(double voltage, CancellationToken ct = default)
{
string valStr = voltage.ToString("0.###", CultureInfo.InvariantCulture);
await SendAsync($"SOUR:SYNT:FUND {valStr}{ScpiDelimiter}", ct);
}
/// <summary>
/// 设定叠加于 SYNTHESIS 波形的直流电压 (SOUR:SYNT:DC)
/// </summary>
/// <param name="voltage">直流电压 (单位: V,-495.0 ~ 495.0)</param>
public virtual async Task 设置合成直流电压Async(double voltage, CancellationToken ct = default)
{
string valStr = voltage.ToString("0.###", CultureInfo.InvariantCulture);
await SendAsync($"SOUR:SYNT:DC {valStr}{ScpiDelimiter}", ct);
}
/// <summary>
/// 设定 SYNTHESIS 模式的基频 (SOUR:SYNT:FREQ)
/// </summary>
/// <param name="frequency">基频: 50 或 60 (Hz)</param>
public virtual async Task 设置合成基频Async(int frequency, CancellationToken ct = default)
{
await SendAsync($"SOUR:SYNT:FREQ {frequency}{ScpiDelimiter}", ct);
}
/// <summary>
/// 设定 SYNTHESIS 模式的起始相位角 (SOUR:SYNT:SPH)
/// </summary>
/// <param name="degree">起始相位角 (单位: °,0.0 ~ 359.9)</param>
public virtual async Task 设置合成起始相位Async(double degree, CancellationToken ct = default)
{
string valStr = degree.ToString("0.#", CultureInfo.InvariantCulture);
await SendAsync($"SOUR:SYNT:SPH {valStr}{ScpiDelimiter}", ct);
}
/// <summary>
/// 查询当前合成数据格式 (SOUR:SYNT:COMP?),用于下发后回读校验
/// </summary>
/// <returns>VALUE1|2|3 / PERCENT1|2|3</returns>
public virtual async Task<string> 查询合成数据格式Async(CancellationToken ct = default)
{
string resp = await WriteReadAsync($"SOUR:SYNT:COMP?{ScpiDelimiter}", ScpiDelimiter, ct);
return resp?.Trim() ?? string.Empty;
}
/// <summary>
/// 查询 SYNTHESIS 基波交流电压 (SOUR:SYNT:FUND?),用于下发后回读校验
/// </summary>
public virtual async Task<double> 查询合成基波电压Async(CancellationToken ct = default)
{
string resp = await WriteReadAsync($"SOUR:SYNT:FUND?{ScpiDelimiter}", ScpiDelimiter, ct);
return ExtractDouble(resp);
}
/// <summary>
/// 查询 SYNTHESIS 基频 (SOUR:SYNT:FREQ?),返回 50 或 60
/// </summary>
public virtual async Task<int> 查询合成基频Async(CancellationToken ct = default)
{
string resp = await WriteReadAsync($"SOUR:SYNT:FREQ?{ScpiDelimiter}", ScpiDelimiter, ct);
return (int)ExtractDouble(resp);
}
/// <summary>
/// 查询 SYNTHESIS 叠加直流电压 (SOUR:SYNT:DC?)
/// </summary>
public virtual async Task<double> 查询合成直流电压Async(CancellationToken ct = default)
{
string resp = await WriteReadAsync($"SOUR:SYNT:DC?{ScpiDelimiter}", ScpiDelimiter, ct);
return ExtractDouble(resp);
}
/// <summary>
/// 查询 SYNTHESIS 起始相位角 (SOUR:SYNT:SPH?)
/// </summary>
public virtual async Task<double> 查询合成起始相位Async(CancellationToken ct = default)
{
string resp = await WriteReadAsync($"SOUR:SYNT:SPH?{ScpiDelimiter}", ScpiDelimiter, ct);
return ExtractDouble(resp);
}
/// <summary>
/// 查询各谐波阶振幅数组 (SOUR:SYNT:AMPL?),首值对应 2 次谐波
/// </summary>
public virtual async Task<double[]> 查询合成谐波振幅Async(CancellationToken ct = default)
{
string resp = await WriteReadAsync($"SOUR:SYNT:AMPL?{ScpiDelimiter}", ScpiDelimiter, ct);
return ExtractDoubleArray(resp);
}
/// <summary>
/// 查询各谐波阶相位角数组 (SOUR:SYNT:PHAS?),首值对应 2 次谐波
/// </summary>
public virtual async Task<double[]> 查询合成谐波相位Async(CancellationToken ct = default)
{
string resp = await WriteReadAsync($"SOUR:SYNT:PHAS?{ScpiDelimiter}", ScpiDelimiter, ct);
return ExtractDoubleArray(resp);
}
#endregion
} }
} }
+66 -90
View File
@@ -11,26 +11,36 @@ using System.ComponentModel;
namespace DeviceCommand.Devices namespace DeviceCommand.Devices
{ {
/// <summary> /// <summary>
/// 系统控制功能类型枚举 (根据三工位 ACP接线图 更新) /// 系统控制功能类型枚举 (根据 ACP硬件接线图.xlsx 三工位系统更新)
/// </summary> /// </summary>
public enum 功能动作 : byte public enum 功能动作 : byte
{ {
产品充电三相, /// <summary>低压电源产品唤醒通断 KL30(三工位,模块1# Y1/Y3/Y5,KA2/KA4/KA6)</summary>
产品放电三相,
KL30通断, KL30通断,
/// <summary>低压电源产品唤醒通断 KL15(三工位,模块1# Y2/Y4/Y6,KA3/KA5/KA7)</summary>
KL15通断, KL15通断,
/// <summary>电子锁60Ω电阻通断(三工位,模块1# Y13/Y14/Y15)</summary>
电子锁60欧电阻, 电子锁60欧电阻,
/// <summary>温度检测1KΩ电阻通断(三工位,模块3# Y1/Y2/Y3)</summary>
温度检测1K欧电阻, 温度检测1K欧电阻,
/// <summary>CC信号串1KΩ电阻(三工位,模块2# Y1/Y5/Y9)</summary>
CC信号串1K欧电阻, CC信号串1K欧电阻,
/// <summary>CP信号串1KΩ电阻(三工位,模块2# Y13/Y14/Y15)</summary>
CP信号串1K欧电阻, CP信号串1K欧电阻,
/// <summary>CC信号串680Ω电阻(三工位,模块2# Y2/Y6/Y10)</summary>
CC信号串680欧电阻, CC信号串680欧电阻,
/// <summary>CC信号串220Ω电阻(三工位,模块2# Y3/Y7/Y11)</summary>
CC信号串220欧电阻, CC信号串220欧电阻,
/// <summary>CC信号串100Ω电阻(三工位,模块2# Y4/Y8/Y12)</summary>
CC信号串100欧电阻, CC信号串100欧电阻,
CC信号空载, /// <summary>蜂鸣器阈值报警(三工位,模块1# Y10/Y11/Y12)</summary>
蜂鸣器报警, 蜂鸣器报警,
抛负载通断, // (接线图中标注于 Y14, KM8) /// <summary>双向直流源(HV+)抛负载通断(仅一工位,模块1# Y7,KA8)</summary>
短路测试HV, // (接线图中标注于 Y15, KM9) 抛负载通断,
短路测试LV // (接线图中标注于 Y16, KM10) /// <summary>双向直流源(HV)短路测试通断(仅一工位,模块1# Y8,KA9)</summary>
短路测试HV,
/// <summary>低压源载一体机(LV)短路测试通断(仅一工位,模块1# Y9,KA10)</summary>
短路测试LV
} }
/// <summary> /// <summary>
@@ -50,90 +60,75 @@ namespace DeviceCommand.Devices
/// <summary> /// <summary>
/// IO 板卡组(三工位统一调度),封装 3 块 IO 继电器板卡的访问。 /// IO 板卡组(三工位统一调度),封装 3 块 IO 继电器板卡的访问。
/// 根据 ACP 硬件接线图的点位映射字典,按台架序号路由到对应板卡的线圈地址, /// 根据 ACP硬件接线图.xlsx 的点位映射字典,按台架序号路由到对应板卡的线圈地址。
/// 并内置充电/放电互锁保护,防止同时吸合造成短路。 /// 注意:模块1# 集中承载三工位的 KL30/KL15 唤醒、蜂鸣器、电子锁及一工位专属的
/// 抛负载/短路测试;模块2# 承载三工位 CC/CP 引导信号电阻;模块3# 承载三工位温度检测电阻。
/// </summary> /// </summary>
[ACPCommand] [ACPCommand]
public class IOBoardGroup public class IOBoardGroup
{ {
// 声明底层的 3 个 IO 板卡实例(对应三工位系统) // 声明底层的 3 个 IO 板卡实例(对应接线图中的 IO继电器模块 1#/2#/3#)
public IOBoard Board1 { get; private set; } public IOBoard Board1 { get; private set; }
public IOBoard Board2 { get; private set; } public IOBoard Board2 { get; private set; }
public IOBoard Board3 { get; private set; } public IOBoard Board3 { get; private set; }
private readonly byte _slaveAddress = 1; // 默认 Modbus 从站号为 1 private readonly byte _slaveAddress = 1; // 默认 Modbus 从站号为 1
// 核心:根据 ACP硬件接线图 严格对齐的数据字典 (3个模块独立对应1-3工位) // 核心:根据 ACP硬件接线图.xlsx 严格对齐的数据字典(按工位分组,点位跨模块路由)
private static readonly Dictionary<int, Dictionary<功能动作, IoPortInfo>> IoMapping private static readonly Dictionary<int, Dictionary<功能动作, IoPortInfo>> IoMapping
= new Dictionary<int, Dictionary<功能动作, IoPortInfo>>() = new Dictionary<int, Dictionary<功能动作, IoPortInfo>>()
{ {
// 1# 工位 -> IO继电器模块 1# // 1# 工位(一工位)
{ 1, new Dictionary<功能动作, IoPortInfo> { { 1, new Dictionary<功能动作, IoPortInfo> {
{ 功能动作.产品充电三相, new IoPortInfo(1, 0) }, // Y1 -> 0 (KM2) { 功能动作.KL30通断, new IoPortInfo(1, 0) }, // 模块1# Y1 -> 0 (KA2, 低压电源KL30唤醒)
{ 功能动作.产品放电三相, new IoPortInfo(1, 1) }, // Y2 -> 1 (KM3) { 功能动作.KL15通断, new IoPortInfo(1, 1) }, // 模块1# Y2 -> 1 (KA3, 低压电源KL15唤醒)
{ 功能动作.KL30通断, new IoPortInfo(1, 2) }, // Y3 -> 2 { 功能动作.抛负载通断, new IoPortInfo(1, 6) }, // 模块1# Y7 -> 6 (KA8, 双向直流源HV+抛负载)
{ 功能动作.KL15通断, new IoPortInfo(1, 3) }, // Y4 -> 3 { 功能动作.短路测试HV, new IoPortInfo(1, 7) }, // 模块1# Y8 -> 7 (KA9, 双向直流源HV短路测试)
{ 功能动作.电子锁60欧电阻, new IoPortInfo(1, 4) }, // Y5 -> 4 { 功能动作.短路测试LV, new IoPortInfo(1, 8) }, // 模块1# Y9 -> 8 (KA10, 低压源载一体机LV短路测试)
{ 功能动作.温度检测1K欧电阻, new IoPortInfo(1, 5) }, // Y6 -> 5 { 功能动作.蜂鸣器报警, new IoPortInfo(1, 9) }, // 模块1# Y10 -> 9 (1#蜂鸣器)
{ 功能动作.CC信号串1K欧电阻, new IoPortInfo(1, 6) }, // Y7 -> 6 { 功能动作.电子锁60欧电阻, new IoPortInfo(1, 12) }, // 模块1# Y13 -> 12 (1#电子锁60Ω)
{ 功能动作.CP信号串1K欧电阻, new IoPortInfo(1, 7) }, // Y8 -> 7 { 功能动作.CC信号串1K欧电阻, new IoPortInfo(2, 0) }, // 模块2# Y1 -> 0 (CC 1KΩ)
{ 功能动作.CC信号串680欧电阻, new IoPortInfo(1, 8) }, // Y9 -> 8 { 功能动作.CC信号串680欧电阻, new IoPortInfo(2, 1) }, // 模块2# Y2 -> 1 (CC 680Ω)
{ 功能动作.CC信号串220欧电阻, new IoPortInfo(1, 9) }, // Y10 -> 9 { 功能动作.CC信号串220欧电阻, new IoPortInfo(2, 2) }, // 模块2# Y3 -> 2 (CC 220Ω)
{ 功能动作.CC信号串100欧电阻, new IoPortInfo(1, 10) }, // Y11 -> 10 { 功能动作.CC信号串100欧电阻, new IoPortInfo(2, 3) }, // 模块2# Y4 -> 3 (CC 100Ω)
{ 功能动作.CC信号空载, new IoPortInfo(1, 11) }, // Y12 -> 11 { 功能动作.CP信号串1K欧电阻, new IoPortInfo(2, 12) }, // 模块2# Y13 -> 12 (CP 1KΩ)
{ 功能动作.蜂鸣器报警, new IoPortInfo(1, 12) }, // Y13 -> 12 { 功能动作.温度检测1K欧电阻, new IoPortInfo(3, 0) } // 模块3# Y1 -> 0 (温度检测1KΩ)
{ 功能动作.抛负载通断, new IoPortInfo(3, 13) }, // Y14 -> 13 (KM8)
{ 功能动作.短路测试HV, new IoPortInfo(3, 14) }, // Y15 -> 14 (KM9)
{ 功能动作.短路测试LV, new IoPortInfo(3, 15) } // Y16 -> 15 (KM10)
}}, }},
// 2# 工位 -> IO继电器模块 2# // 2# 工位(二工位)
{ 2, new Dictionary<功能动作, IoPortInfo> { { 2, new Dictionary<功能动作, IoPortInfo> {
{ 功能动作.产品充电三相, new IoPortInfo(2, 0) }, // Y1 -> 0 (KM4) { 功能动作.KL30通断, new IoPortInfo(1, 2) }, // 模块1# Y3 -> 2 (KA4, 低压电源KL30唤醒)
{ 功能动作.产品放电三相, new IoPortInfo(2, 1) }, // Y2 -> 1 (KM5) { 功能动作.KL15通断, new IoPortInfo(1, 3) }, // 模块1# Y4 -> 3 (KA5, 低压电源KL15唤醒)
{ 功能动作.KL30通断, new IoPortInfo(2, 2) }, // Y3 -> 2 { 功能动作.蜂鸣器报警, new IoPortInfo(1, 10) }, // 模块1# Y11 -> 10 (2#蜂鸣器)
{ 功能动作.KL15通断, new IoPortInfo(2, 3) }, // Y4 -> 3 { 功能动作.电子锁60欧电阻, new IoPortInfo(1, 13) }, // 模块1# Y14 -> 13 (2#电子锁60Ω)
{ 功能动作.电子锁60欧电阻, new IoPortInfo(2, 4) }, // Y5 -> 4 { 功能动作.CC信号串1K欧电阻, new IoPortInfo(2, 4) }, // 模块2# Y5 -> 4 (CC 1KΩ)
{ 功能动作.温度检测1K欧电阻, new IoPortInfo(2, 5) }, // Y6 -> 5 { 功能动作.CC信号串680欧电阻, new IoPortInfo(2, 5) }, // 模块2# Y6 -> 5 (CC 680Ω)
{ 功能动作.CC信号串1K欧电阻, new IoPortInfo(2, 6) }, // Y7 -> 6 { 功能动作.CC信号串220欧电阻, new IoPortInfo(2, 6) }, // 模块2# Y7 -> 6 (CC 220Ω)
{ 功能动作.CP信号串1K欧电阻, new IoPortInfo(2, 7) }, // Y8 -> 7 { 功能动作.CC信号串100欧电阻, new IoPortInfo(2, 7) }, // 模块2# Y8 -> 7 (CC 100Ω)
{ 功能动作.CC信号串680欧电阻, new IoPortInfo(2, 8) }, // Y9 -> 8 { 功能动作.CP信号串1K欧电阻, new IoPortInfo(2, 13) }, // 模块2# Y14 -> 13 (CP 1KΩ)
{ 功能动作.CC信号串220欧电阻, new IoPortInfo(2, 9) }, // Y10 -> 9 { 功能动作.温度检测1K欧电阻, new IoPortInfo(3, 1) } // 模块3# Y2 -> 1 (温度检测1KΩ)
{ 功能动作.CC信号串100欧电阻, new IoPortInfo(2, 10) }, // Y11 -> 10
{ 功能动作.CC信号空载, new IoPortInfo(2, 11) }, // Y12 -> 11
{ 功能动作.蜂鸣器报警, new IoPortInfo(2, 12) }, // Y13 -> 12
{ 功能动作.抛负载通断, new IoPortInfo(3, 13) }, // Y14 -> 13 (KM8)
{ 功能动作.短路测试HV, new IoPortInfo(3, 14) }, // Y15 -> 14 (KM9)
{ 功能动作.短路测试LV, new IoPortInfo(3, 15) } // Y16 -> 15 (KM10)
}}, }},
// 3# 工位 -> IO继电器模块 3# (包含额外的抛负载及短路测试控制) // 3# 工位(三工位)
{ 3, new Dictionary<功能动作, IoPortInfo> { { 3, new Dictionary<功能动作, IoPortInfo> {
{ 功能动作.产品充电三相, new IoPortInfo(3, 0) }, // Y1 -> 0 (KM6) { 功能动作.KL30通断, new IoPortInfo(1, 4) }, // 模块1# Y5 -> 4 (KA6, 低压电源KL30唤醒)
{ 功能动作.产品放电三相, new IoPortInfo(3, 1) }, // Y2 -> 1 (KM7) { 功能动作.KL15通断, new IoPortInfo(1, 5) }, // 模块1# Y6 -> 5 (KA7, 低压电源KL15唤醒)
{ 功能动作.KL30通断, new IoPortInfo(3, 2) }, // Y3 -> 2 { 功能动作.蜂鸣器报警, new IoPortInfo(1, 11) }, // 模块1# Y12 -> 11 (3#蜂鸣器)
{ 功能动作.KL15通断, new IoPortInfo(3, 3) }, // Y4 -> 3 { 功能动作.电子锁60欧电阻, new IoPortInfo(1, 14) }, // 模块1# Y15 -> 14 (3#电子锁60Ω)
{ 功能动作.电子锁60欧电阻, new IoPortInfo(3, 4) }, // Y5 -> 4 { 功能动作.CC信号串1K欧电阻, new IoPortInfo(2, 8) }, // 模块2# Y9 -> 8 (CC 1KΩ)
{ 功能动作.温度检测1K欧电阻, new IoPortInfo(3, 5) }, // Y6 -> 5 { 功能动作.CC信号串680欧电阻, new IoPortInfo(2, 9) }, // 模块2# Y10 -> 9 (CC 680Ω)
{ 功能动作.CC信号串1K欧电阻, new IoPortInfo(3, 6) }, // Y7 -> 6 { 功能动作.CC信号串220欧电阻, new IoPortInfo(2, 10) }, // 模块2# Y11 -> 10 (CC 220Ω)
{ 功能动作.CP信号串1K欧电阻, new IoPortInfo(3, 7) }, // Y8 -> 7 { 功能动作.CC信号串100欧电阻, new IoPortInfo(2, 11) }, // 模块2# Y12 -> 11 (CC 100Ω)
{ 功能动作.CC信号串680欧电阻, new IoPortInfo(3, 8) }, // Y9 -> 8 { 功能动作.CP信号串1K欧电阻, new IoPortInfo(2, 14) }, // 模块2# Y15 -> 14 (CP 1KΩ)
{ 功能动作.CC信号串220欧电阻, new IoPortInfo(3, 9) }, // Y10 -> 9 { 功能动作.温度检测1K欧电阻, new IoPortInfo(3, 2) } // 模块3# Y3 -> 2 (温度检测1KΩ)
{ 功能动作.CC信号串100欧电阻, new IoPortInfo(3, 10) }, // Y11 -> 10
{ 功能动作.CC信号空载, new IoPortInfo(3, 11) }, // Y12 -> 11
{ 功能动作.蜂鸣器报警, new IoPortInfo(3, 12) }, // Y13 -> 12
{ 功能动作.抛负载通断, new IoPortInfo(3, 13) }, // Y14 -> 13 (KM8)
{ 功能动作.短路测试HV, new IoPortInfo(3, 14) }, // Y15 -> 14 (KM9)
{ 功能动作.短路测试LV, new IoPortInfo(3, 15) } // Y16 -> 15 (KM10)
}} }}
}; };
/// <summary> /// <summary>
/// 构造函数,注入三个工位的 IO 板卡实例。 /// 构造函数,注入三个 IO 板卡实例。
/// </summary> /// </summary>
/// <param name="board1">1# 工位 IO 板卡实例</param> /// <param name="board1">IO继电器模块 1# 板卡实例</param>
/// <param name="board2">2# 工位 IO 板卡实例</param> /// <param name="board2">IO继电器模块 2# 板卡实例</param>
/// <param name="board3">3# 工位 IO 板卡实例</param> /// <param name="board3">IO继电器模块 3# 板卡实例</param>
/// <exception cref="ArgumentNullException">任一块板卡实例为空时抛出</exception> /// <exception cref="ArgumentNullException">任一块板卡实例为空时抛出</exception>
// 修改构造函数以支持 3 个板块
public IOBoardGroup(IOBoard board1, IOBoard board2, IOBoard board3) public IOBoardGroup(IOBoard board1, IOBoard board2, IOBoard board3)
{ {
Board1 = board1 ?? throw new ArgumentNullException(nameof(board1), "IOBoard Board1 实例不能为空。"); Board1 = board1 ?? throw new ArgumentNullException(nameof(board1), "IOBoard Board1 实例不能为空。");
@@ -142,7 +137,7 @@ namespace DeviceCommand.Devices
} }
/// <summary> /// <summary>
/// 执行指定台架的功能动作控制(带高低电平开关及底层安全互锁) /// 执行指定台架的功能动作控制(按接线图字典跨模块路由到对应线圈地址)
/// </summary> /// </summary>
/// <param name="台架序号">台架/工位序号 (1 - 3)</param> /// <param name="台架序号">台架/工位序号 (1 - 3)</param>
/// <param name="动作">控制功能类型</param> /// <param name="动作">控制功能类型</param>
@@ -153,29 +148,10 @@ namespace DeviceCommand.Devices
// 1. 根据传入的台架序号和动作检索字典映射关系 // 1. 根据传入的台架序号和动作检索字典映射关系
if (!IoMapping.TryGetValue(台架序号, out var 动作字典) || !动作字典.TryGetValue(动作, out var io信息)) if (!IoMapping.TryGetValue(台架序号, out var 动作字典) || !动作字典.TryGetValue(动作, out var io信息))
{ {
throw new ArgumentException($"未找到台架 [{台架序号}] 对应功能 [{动作}] 的 IO 点位映射配置。请注意抛负载及短路测试仅存在于 3# 工位。"); throw new ArgumentException($"未找到台架 [{台架序号}] 对应功能 [{动作}] 的 IO 点位映射配置。请注意抛负载及短路测试(HV/LV)仅存在于 1# 工位。");
} }
// 2. 软件防错高危互锁逻辑:防止同时执行充电和放电 // 2. 动态获取目标板卡实例并直接驱动开关端口
if (开关状态)
{
if (动作 == 功能动作.产品充电三相)
{
if (IoMapping[台架序号].TryGetValue(功能动作.产品放电三相, out var 放电点位))
{
await GetBoardInstance(放电点位.ModuleIndex).写输出开关(_slaveAddress, 放电点位.Address, false);
}
}
else if (动作 == 功能动作.产品放电三相)
{
if (IoMapping[台架序号].TryGetValue(功能动作.产品充电三相, out var 充电点位))
{
await GetBoardInstance(充电点位.ModuleIndex).写输出开关(_slaveAddress, 充电点位.Address, false);
}
}
}
// 3. 动态获取目标板卡实例并直接驱动开关端口
IOBoard 目标板卡 = GetBoardInstance(io信息.ModuleIndex); IOBoard 目标板卡 = GetBoardInstance(io信息.ModuleIndex);
await 目标板卡.写输出开关(_slaveAddress, io信息.Address, 开关状态); await 目标板卡.写输出开关(_slaveAddress, io信息.Address, 开关状态);
} }
@@ -212,4 +188,4 @@ namespace DeviceCommand.Devices
return board ?? throw new InvalidOperationException($"IO板卡模块 [{moduleIndex}#] 尚未完成初始化,无法建立总线通信。"); return board ?? throw new InvalidOperationException($"IO板卡模块 [{moduleIndex}#] 尚未完成初始化,无法建立总线通信。");
} }
} }
} }
+62 -197
View File
@@ -2,7 +2,6 @@
using DeviceCommand.Base; using DeviceCommand.Base;
using Model.Models; using Model.Models;
using System; using System;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -10,237 +9,120 @@ using System.Threading.Tasks;
namespace DeviceCommand.Devices namespace DeviceCommand.Devices
{ {
/// <summary> /// <summary>
/// 低压电源 一拖三 /// 低压电源 一拖三(ITECH IT67xx 系列,标准 SCPI 指令集)。
/// <para>传输层为 VISA(USB VISA 资源字符串寻址,如 USB0::VID::PID::SN::INSTR)。</para>
/// <para>SCPI 命令语法参照同家族已在实机验证的实现 BOB/DeviceCommand/Devices/IT6724C.cs:
/// 设定类命令只发不读(SCPI 设置命令无应答,避免空等超时),查询类命令走 WriteReadAsync 读取一行响应。</para>
/// </summary> /// </summary>
[ACPCommand] [ACPCommand]
public class IT6720: Serial_Port // 直接继承您提供的 Serial_Port 基类 public class IT6720 : Visa
{ {
// 帧格式:同步头 AA + 地址 + 命令字 + 数据[4-25] + 校验和,共 26 字节 // SCPI 指令结束符(VISA 消息终止符使用 LF)
private const byte FrameSyncByte = 0xAA; private const string ScpiDelimiter = "\n";
private const int FrameLength = 26;
private readonly byte _deviceAddress = 0x00; // 电源地址
public IT6720(SerialPortConfig config) : base(config) // 最近一次设定的限流值(A):IT67xx SCPI 无直接 CV/CC 查询,用"实测电流是否顶到限流"启发式判断输出模式
private double _setCurrentLimit;
public IT6720(VisaConfig config) : base(config)
{ {
} }
#region 核心协议封装(不变) #region SCPI 收发辅助
/// <summary> /// <summary>以不变文化格式化数值,避免小数分隔符随系统区域变化。</summary>
/// 构建 26 字节的发送帧,并计算校验和 private static string Fmt(double value) => value.ToString(CultureInfo.InvariantCulture);
/// </summary>
private byte[] BuildFrame(byte command, byte[] dataBytes)
{
List<byte> frame = new List<byte> { FrameSyncByte, _deviceAddress, command };
if (dataBytes != null && dataBytes.Length > 0) /// <summary>宽松解析仪器回传的数值字符串,失败返回 0。</summary>
{ private static double ParseDouble(string raw)
frame.AddRange(dataBytes); => double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double v) ? v : 0;
}
// 数据区必须占满 23 字节 (索引 3 到 24) /// <summary>发送设定类 SCPI 命令(只发不读:SCPI 设置命令无应答,读取会空等超时)。</summary>
while (frame.Count < 25) private async Task SetAsync(string command, CancellationToken ct)
{ => await SendAsync($"{command}{ScpiDelimiter}", ct).ConfigureAwait(false);
frame.Add(0x00);
}
// 计算校验和:前 25 字节之和取低 8 位 /// <summary>发送查询类 SCPI 命令并读取一行响应。</summary>
byte checkSum = 0; private async Task<string> QueryAsync(string query, CancellationToken ct)
for (int i = 0; i < 25; i++) => await WriteReadAsync($"{query}{ScpiDelimiter}", ScpiDelimiter, ct).ConfigureAwait(false);
{
checkSum += frame[i];
}
frame.Add(checkSum);
return frame.ToArray();
}
/// <summary>
/// 检查返回的 26 字节响应帧是否正确
/// </summary>
private bool ValidateResponseFrame(byte[] response)
{
if (response == null || response.Length != FrameLength) return false;
if (response[0] != FrameSyncByte) return false;
if (response[1] != _deviceAddress) return false;
byte checkSum = 0;
for (int i = 0; i < 25; i++)
{
checkSum += response[i];
}
return response[25] == checkSum;
}
/// <summary>
/// 将实际电压/电流值转换成协议要求的整型 (乘以 1000 变成整形)
/// </summary>
private int ConvertToProtocolValue(double value)
{
return (int)Math.Round(value * 1000);
}
/// <summary>
/// 将协议返回的 4 字节小端序转换为实际数值 (除以 1000.0)
/// </summary>
private double Convert4BytesToValue(byte b0, byte b1, byte b2, byte b3)
{
uint raw = (uint)(b0 | (b1 << 8) | (b2 << 16) | (b3 << 24));
return raw / 1000.0;
}
/// <summary>
/// 将协议返回的 2 字节小端序转换为实际数值 (除以 1000.0)
/// </summary>
private double Convert2BytesToValue(byte b0, byte b1)
{
ushort raw = (ushort)(b0 | (b1 << 8));
return raw / 1000.0;
}
#endregion #endregion
#region 1. 核心二进制收发方法(使用基类新方法) #region 1. 操作模式与输出控制
/// <summary> /// <summary>
/// 发送命令帧并读取仪器返回的 26 字节数据帧 /// 切换电源控制模式:PC 远程控制 (SYSTem:REMote) / 面板本地控制 (SYSTem:LOCal)
/// </summary>
private async Task<byte[]> SendAndReadFrameAsync(byte command, byte[] dataBytes, CancellationToken ct = default)
{
byte[] sendFrame = BuildFrame(command, dataBytes);
// 使用基类提供的 WriteBytesAsync 发送二进制数据
await WriteBytesAsync(sendFrame, ct).ConfigureAwait(false);
// 使用基类提供的 ReadBytesAsync 读取定长 26 字节二进制数据
// 注意:这里的 ReadTimeout 由基类统一控制
return await ReadBytesAsync(FrameLength, ct).ConfigureAwait(false);
}
#endregion
#region 2. 操作模式与输出控制
/// <summary>
/// 切换电源控制模式:PC 远程控制 / 面板控制
/// </summary> /// </summary>
public virtual async Task 切换远程控制模式(bool 远程控制 = true, CancellationToken ct = default) public virtual async Task 切换远程控制模式(bool 远程控制 = true, CancellationToken ct = default)
{ => await SetAsync(远程控制 ? "SYSTem:REMote" : "SYSTem:LOCal", ct);
byte[] data = new byte[] { (byte)(远程控制 ? 0x01 : 0x00) };
byte[] response = await SendAndReadFrameAsync(0x20, data, ct);
if (!ValidateResponseFrame(response))
throw new Exception("IT6720 切换控制模式时通讯校验失败。");
}
/// <summary> /// <summary>
/// 切换为本地(面板)控制模式,与远程控制互斥 /// 切换为本地(面板)控制模式,与远程控制互斥
/// </summary> /// </summary>
/// <param name="ct">取消令牌</param> /// <param name="ct">取消令牌</param>
public virtual async Task 切换本地控制模式(CancellationToken ct = default) public virtual async Task 切换本地控制模式(CancellationToken ct = default)
{ => await 切换远程控制模式(false, ct);
await 切换远程控制模式(false, ct);
}
/// <summary> /// <summary>
/// 开启或关闭电源输出 /// 开启或关闭电源输出 (OUTPut 1/0)
/// </summary> /// </summary>
public virtual async Task 设置输出开关(bool 开启 = true, CancellationToken ct = default) public virtual async Task 设置输出开关(bool 开启 = true, CancellationToken ct = default)
{ => await SetAsync($"OUTPut {(开启 ? 1 : 0)}", ct);
byte[] data = new byte[] { (byte)(开启 ? 0x01 : 0x00) };
byte[] response = await SendAndReadFrameAsync(0x21, data, ct);
if (!ValidateResponseFrame(response))
throw new Exception("IT6720 设置输出时通讯校验失败。");
}
/// <summary> /// <summary>
/// 查询电源输出开关状态(由实时测量值的状态字节判断) /// 查询电源输出开关状态 (OUTPut?)
/// </summary> /// </summary>
/// <param name="ct">取消令牌</param> /// <param name="ct">取消令牌</param>
/// <returns>输出开关状态字符串 (ON / OFF)</returns> /// <returns>输出开关状态字符串 (ON / OFF)</returns>
public virtual async Task<string> 查询输出开关状态(CancellationToken ct = default) public virtual async Task<string> 查询输出开关状态(CancellationToken ct = default)
{ {
var (_, _, status) = await 查询实时测量值(ct); string r = (await QueryAsync("OUTPut?", ct)).Trim();
return (status & 0x01) != 0 ? "ON" : "OFF"; return (r == "1" || r.StartsWith("ON", StringComparison.OrdinalIgnoreCase)) ? "ON" : "OFF";
} }
#endregion #endregion
#region 3. 电压 / 电流设定 #region 2. 电压 / 电流设定
/// <summary> /// <summary>
/// 设置电源的电压上限 /// 设置电源的电压上限 / 过压保护阈值 (VOLT:PROTection)
/// </summary> /// </summary>
public virtual async Task 设置电压上限(double 电压, CancellationToken ct = default) public virtual async Task 设置电压上限(double 电压, CancellationToken ct = default)
{ => await SetAsync($"VOLT:PROTection {Fmt(电压)}", ct);
int raw = ConvertToProtocolValue(电压);
byte[] data = new byte[]
{
(byte)(raw & 0xFF), (byte)((raw >> 8) & 0xFF),
(byte)((raw >> 16) & 0xFF), (byte)((raw >> 24) & 0xFF)
};
byte[] response = await SendAndReadFrameAsync(0x22, data, ct);
if (!ValidateResponseFrame(response))
throw new Exception("IT6720 设置电压上限时通讯校验失败。");
}
/// <summary> /// <summary>
/// 设置电源的输出电压 /// 设置电源的输出电压 (VOLTage)
/// </summary> /// </summary>
public virtual async Task 设置输出电压(double 电压, CancellationToken ct = default) public virtual async Task 设置输出电压(double 电压, CancellationToken ct = default)
{ => await SetAsync($"VOLTage {Fmt(电压)}", ct);
int raw = ConvertToProtocolValue(电压);
byte[] data = new byte[]
{
(byte)(raw & 0xFF), (byte)((raw >> 8) & 0xFF),
(byte)((raw >> 16) & 0xFF), (byte)((raw >> 24) & 0xFF)
};
byte[] response = await SendAndReadFrameAsync(0x23, data, ct);
if (!ValidateResponseFrame(response))
throw new Exception("IT6720 设置输出电压时通讯校验失败。");
}
/// <summary> /// <summary>
/// 设置电源的输出电流限流值 /// 设置电源的输出电流限流值 (CURRent)
/// </summary> /// </summary>
public virtual async Task 设置输出电流(double 电流, CancellationToken ct = default) public virtual async Task 设置输出电流(double 电流, CancellationToken ct = default)
{ {
int raw = ConvertToProtocolValue(电流); _setCurrentLimit = 电流;
byte[] data = new byte[] await SetAsync($"CURRent {Fmt(电流)}", ct);
{
(byte)(raw & 0xFF), (byte)((raw >> 8) & 0xFF)
};
byte[] response = await SendAndReadFrameAsync(0x24, data, ct);
if (!ValidateResponseFrame(response))
throw new Exception("IT6720 设置输出电流时通讯校验失败。");
} }
#endregion #endregion
#region 4. 读取实时数据与状态(对应 0x26H) #region 3. 读取实时数据与状态
/// <summary> /// <summary>
/// 读取电源的实时电流、电压、设定值及电源状态 /// 读取电源的实时电流、电压及输出开关状态
/// 返回元组:实际电流(A), 实际电压(V), 状态字节(int) /// 返回元组:实际电流(A, MEASure:CURRent?), 实际电压(V, MEASure:VOLTage?), 状态字节(int, 位0=输出开关)
/// </summary> /// </summary>
[Monitorable("IT6720 实时测量数据")] [Monitorable("IT6720 实时测量数据")]
public virtual async Task<(double ActualCurrent, double ActualVoltage, int StatusByte)> 查询实时测量值(CancellationToken ct = default) public virtual async Task<(double ActualCurrent, double ActualVoltage, int StatusByte)> 查询实时测量值(CancellationToken ct = default)
{ {
byte[] response = await SendAndReadFrameAsync(0x26, new byte[0], ct); double current = ParseDouble(await QueryAsync("MEASure:CURRent?", ct));
double voltage = ParseDouble(await QueryAsync("MEASure:VOLTage?", ct));
if (!ValidateResponseFrame(response)) string on = (await QueryAsync("OUTPut?", ct)).Trim();
throw new Exception("IT6720 读取数据时通讯校验失败。"); int statusByte = (on == "1" || on.StartsWith("ON", StringComparison.OrdinalIgnoreCase)) ? 0x01 : 0x00;
return (current, voltage, statusByte);
double actualCurrent = Convert2BytesToValue(response[4], response[5]);
double actualVoltage = Convert4BytesToValue(response[6], response[7], response[8], response[9]);
int statusByte = response[10];
return (actualCurrent, actualVoltage, statusByte);
} }
/// <summary> /// <summary>
/// 查询电源的实时实际输出电压 (MEASure 相关寄存器) /// 查询电源的实时实际输出电压 (MEASure:VOLTage?)
/// </summary> /// </summary>
/// <param name="ct">取消令牌</param> /// <param name="ct">取消令牌</param>
/// <returns>实时电压值 (单位: V,保留两位小数)</returns> /// <returns>实时电压值 (单位: V,保留两位小数)</returns>
@@ -252,7 +134,7 @@ namespace DeviceCommand.Devices
} }
/// <summary> /// <summary>
/// 查询电源的实时实际输出电流 (MEASure 相关寄存器) /// 查询电源的实时实际输出电流 (MEASure:CURRent?)
/// </summary> /// </summary>
/// <param name="ct">取消令牌</param> /// <param name="ct">取消令牌</param>
/// <returns>实时电流值 (单位: A,保留两位小数)</returns> /// <returns>实时电流值 (单位: A,保留两位小数)</returns>
@@ -264,47 +146,30 @@ namespace DeviceCommand.Devices
} }
/// <summary> /// <summary>
/// 读取状态字,判断是否处于恒压 (CV) 模式或恒流 (CC) 模式 /// 判断输出处于恒压 (CV) 还是恒流 (CC) 模式。
/// 状态字节位详解:位 2,3 = 1(CV), 2(CC) /// IT67xx SCPI 无直接 CV/CC 查询命令,此处以"实测电流是否顶到限流设定值"启发式判断:
/// 输出关闭返回 OFF;实测电流达到限流值(≥98%)判为 CC,否则 CV。
/// </summary> /// </summary>
public virtual async Task<string> 查询输出模式(CancellationToken ct = default) public virtual async Task<string> 查询输出模式(CancellationToken ct = default)
{ {
var (_, _, status) = await 查询实时测量值(ct); var (current, _, status) = await 查询实时测量值(ct);
int mode = (status >> 2) & 0x03; if ((status & 0x01) == 0) return "OFF";
return mode switch if (_setCurrentLimit > 0 && current >= _setCurrentLimit * 0.98) return "CC";
{ return "CV";
1 => "CV",
2 => "CC",
_ => "UNKNOWN"
};
} }
#endregion #endregion
#region 5. 读取设备信息(命令字 31H) #region 4. 设备信息与错误
/// <summary> /// <summary>
/// 读取电源的序列号、型号及软件版本 /// 读取电源的标识信息 (*IDN?:制造商, 型号, 序列号, 固件版本)
/// </summary> /// </summary>
public virtual async Task<string> 查询设备信息(CancellationToken ct = default) public virtual async Task<string> 查询设备信息(CancellationToken ct = default)
{ => await QueryAsync("*IDN?", ct);
byte[] response = await SendAndReadFrameAsync(0x31, new byte[0], ct);
if (!ValidateResponseFrame(response)) return "Invalid Response";
string model = System.Text.Encoding.ASCII.GetString(response, 4, 5).Trim('\0');
string swVersion = $"{response[9]:X2}.{response[10]:X2}";
string serialNum = System.Text.Encoding.ASCII.GetString(response, 11, 10).Trim('\0');
return $"Model:{model}, SW:{swVersion}, SN:{serialNum}";
}
#endregion
#region 6. 扩展系统命令(兼容接口)
/// <summary> /// <summary>
/// 基类自带的超时和清空缓冲区已经处理了大部分错误。 /// IT67xx 未提供标准错误队列查询,返回面板提示说明
/// 如需在面板显示具体错误,需通过状态位自行判断或查看面板 ERROR 灯。
/// </summary> /// </summary>
public virtual async Task<string> 查询错误信息(CancellationToken ct = default) public virtual async Task<string> 查询错误信息(CancellationToken ct = default)
{ {
@@ -313,4 +178,4 @@ namespace DeviceCommand.Devices
#endregion #endregion
} }
} }
+3 -2
View File
@@ -236,7 +236,8 @@ namespace DeviceCommand.Devices
/// <param name="ct">取消令牌</param> /// <param name="ct">取消令牌</param>
public async Task 设置CV正向电流(double 电流, CancellationToken ct = default) public async Task 设置CV正向电流(double 电流, CancellationToken ct = default)
{ {
await SendAsync($"CURRent:LIMit:POSitive {电流}{ScpiDelimiter}", ct); await SendAsync($"VOLTage:ILIMit {电流}{ScpiDelimiter}", ct);
} }
/// <summary> /// <summary>
@@ -247,7 +248,7 @@ namespace DeviceCommand.Devices
public async Task 设置CV反向电流(double 电流, CancellationToken ct = default) public async Task 设置CV反向电流(double 电流, CancellationToken ct = default)
{ {
// 载模式限制需要负数,若传入正数建议在外部转换为负值 // 载模式限制需要负数,若传入正数建议在外部转换为负值
await SendAsync($"CURRent:LIMit:NEGative {电流}{ScpiDelimiter}", ct); await SendAsync($"VOLTage:ILIMit:NEGative {电流}{ScpiDelimiter}", ct);
} }
/// <summary> /// <summary>
+3 -2
View File
@@ -186,8 +186,9 @@ namespace MonitorModule.ViewModels
if (!string.IsNullOrEmpty(TestStatus)) if (!string.IsNullOrEmpty(TestStatus))
_canSignalBroadcaster?.UnregisterScope(TestStatus); _canSignalBroadcaster?.UnregisterScope(TestStatus);
// 3. 释放容器作用域 // 注意:_scope 是从 GlobalInfo.ScopeDic 借用的工位作用域,拥有者是 AutomatedTestingViewModel,
_scope?.Dispose(); // 这里不能 Dispose,否则关闭监控页就会销毁整个工位(DeviceManager/SystemConfig 全部失效,
// 并让 CAN 卡的作用域引用计数提前归零)。只清理本页面自己的资源即可。
Channels.Clear(); Channels.Clear();
AvailableMethods.Clear(); AvailableMethods.Clear();
} }
+2 -1
View File
@@ -173,7 +173,8 @@ namespace MonitorModule.ViewModels
public void Dispose() public void Dispose()
{ {
_scope?.Dispose(); // _scope 来自 GlobalInfo.ScopeDic,拥有者是 AutomatedTestingViewModel,
// 借用方不能 Dispose,否则关闭查询页会销毁整个工位并让 CAN 引用计数提前归零
} }
#region 筛选条件加载 #region 筛选条件加载
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,6 @@
xmlns:prism="http://prismlibrary.com/" xmlns:prism="http://prismlibrary.com/"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:converters="clr-namespace:UIShare.Converters;assembly=UIShare" xmlns:converters="clr-namespace:UIShare.Converters;assembly=UIShare"
xmlns:oxy="http://oxyplot.org/wpf"
prism:ViewModelLocator.AutoWireViewModel="True" prism:ViewModelLocator.AutoWireViewModel="True"
d:DesignHeight="1080" d:DesignHeight="1080"
d:DesignWidth="1920"> d:DesignWidth="1920">
@@ -19,8 +18,8 @@
<Border Background="#F5F7FA"> <Border Background="#F5F7FA">
<Grid Margin="8"> <Grid Margin="8">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="440" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="360" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- ============ 左侧:Chroma 命令面板 ============ --> <!-- ============ 左侧:Chroma 命令面板 ============ -->
@@ -97,6 +96,9 @@
Style="{StaticResource WarnBtn}" /> Style="{StaticResource WarnBtn}" />
<Button Content="复位" Command="{Binding ResetCommand}" <Button Content="复位" Command="{Binding ResetCommand}"
Style="{StaticResource CmdBtn}" /> Style="{StaticResource CmdBtn}" />
<Button Content="退出远程" Command="{Binding ExitRemoteCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="SYST:LOC:退出远程状态、恢复前面板操作(手册注明僅 RS-232 控制下有效)" />
<Button Content="查询 IDN" Command="{Binding QueryIdnCommand}" <Button Content="查询 IDN" Command="{Binding QueryIdnCommand}"
Style="{StaticResource CmdBtn}" /> Style="{StaticResource CmdBtn}" />
</StackPanel> </StackPanel>
@@ -161,7 +163,8 @@
<TextBlock Text="波形" Style="{StaticResource ParamLabel}" /> <TextBlock Text="波形" Style="{StaticResource ParamLabel}" />
<ComboBox Width="130" Height="32" FontSize="12" Margin="4,0" <ComboBox Width="130" Height="32" FontSize="12" Margin="4,0"
ItemsSource="{Binding WaveformOptions}" ItemsSource="{Binding WaveformOptions}"
SelectedItem="{Binding Waveform}" /> SelectedItem="{Binding Waveform}"
ToolTip="FUNC:SHAP:A — SINE / SQUA / TRIAN(三角)/ CSIN、内建 DST01~30、自定义 USR01~30" />
<Button Content="设置" Command="{Binding SetWaveformCommand}" <Button Content="设置" Command="{Binding SetWaveformCommand}"
Style="{StaticResource CmdBtn}" /> Style="{StaticResource CmdBtn}" />
</StackPanel> </StackPanel>
@@ -183,11 +186,15 @@
<StackPanel Orientation="Horizontal" Margin="0,4"> <StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="名称" Style="{StaticResource ParamLabel}" /> <TextBlock Text="名称" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="150" <TextBox Style="{StaticResource NumInput}" Width="150"
ToolTip="保存时作为文件名(写入 D:\acp\源测试);选中已有测试项会自动填充,可改名另存" ToolTip="另存为对话框的默认文件名(写入 D:\acp\源测试);载入文件后自动回显当前文件名,保存不改动文件名"
Text="{Binding PresetName, UpdateSourceTrigger=PropertyChanged}" /> Text="{Binding PresetName, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="保存" Command="{Binding SavePresetCommand}" <Button Content="保存" Command="{Binding SavePresetCommand}"
Style="{StaticResource CmdBtn}" Style="{StaticResource CmdBtn}"
ToolTip="覆盖保存当前打开的测试项文件(不改变文件名);未打开文件时按“另存为”新建"
Background="#388E3C" Foreground="White" /> Background="#388E3C" Foreground="White" />
<Button Content="另存为" Command="{Binding SavePresetAsCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="弹出文件对话框选择 .json 位置另存;存入 D:\acp\源测试 才会出现在下拉列表" />
<Button Content="删除" Command="{Binding DeletePresetCommand}" <Button Content="删除" Command="{Binding DeletePresetCommand}"
Style="{StaticResource WarnBtn}" Style="{StaticResource WarnBtn}"
ToolTip="删除下拉框当前选中的测试项文件(带二次确认)" /> ToolTip="删除下拉框当前选中的测试项文件(带二次确认)" />
@@ -222,6 +229,11 @@
<ComboBox Width="80" Height="32" FontSize="12" Margin="4,0" <ComboBox Width="80" Height="32" FontSize="12" Margin="4,0"
ItemsSource="{Binding ListTimeBaseOptions}" ItemsSource="{Binding ListTimeBaseOptions}"
SelectedItem="{Binding ListTimeBase}" /> SelectedItem="{Binding ListTimeBase}" />
<TextBlock Text="相位连续" Style="{StaticResource ParamLabel}" />
<ComboBox Width="88" Height="32" FontSize="12" Margin="4,0"
ToolTip="SOUR:LIST:PHAS:CONT — 相位连续功能:DISABLE | ENABLE"
ItemsSource="{Binding ListPhaseContinueOptions}"
SelectedItem="{Binding ListPhaseContinue}" />
</StackPanel> </StackPanel>
<Separator Margin="0,6" /> <Separator Margin="0,6" />
@@ -230,10 +242,6 @@
<ComboBox Width="100" Height="32" FontSize="12" Margin="4,0" <ComboBox Width="100" Height="32" FontSize="12" Margin="4,0"
ItemsSource="{Binding ListPhaseOptions}" ItemsSource="{Binding ListPhaseOptions}"
SelectedIndex="{Binding ListEditPhaseIndex}" /> SelectedIndex="{Binding ListEditPhaseIndex}" />
<TextBlock Text="该相波形" Style="{StaticResource ParamLabel}" />
<ComboBox Width="80" Height="32" FontSize="12" Margin="4,0"
ItemsSource="{Binding WaveformOptions}"
SelectedItem="{Binding ListWaveform}" />
</StackPanel> </StackPanel>
<Button Content="复制当前相到三相(B/C 相位角自动 ±120°)" <Button Content="复制当前相到三相(B/C 相位角自动 ±120°)"
Command="{Binding CopyPhaseToThreeCommand}" Command="{Binding CopyPhaseToThreeCommand}"
@@ -309,7 +317,7 @@
<TextBlock Grid.Row="8" Grid.Column="0" Text="波形顺序" <TextBlock Grid.Row="8" Grid.Column="0" Text="波形顺序"
Style="{StaticResource ParamLabel}" /> Style="{StaticResource ParamLabel}" />
<TextBox Grid.Row="8" Grid.Column="1" Style="{StaticResource NumInput}" <TextBox Grid.Row="8" Grid.Column="1" Style="{StaticResource NumInput}"
Width="240" ToolTip="LIST:SHAP,A / B 序列,如 A,A,A(可留空)" Width="240" ToolTip="LIST:SHAP 序列:可直接写形状名 SINE/SQUA/TRIAN/CSIN/DST01~30/USR01~30,或引用 A/B 组,如 SINE,SQUA,TRIAN(可留空)"
Text="{Binding ListShapeText, UpdateSourceTrigger=PropertyChanged}" /> Text="{Binding ListShapeText, UpdateSourceTrigger=PropertyChanged}" />
</Grid> </Grid>
@@ -324,45 +332,107 @@
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
<!-- 数据采集 --> <!-- SYNTHESIS 自定义谐波波形输出 -->
<GroupBox Header="数据采集" Margin="0,0,0,8" <GroupBox Header="SYNTHESIS 波形输出(自定义谐波)" Margin="0,0,0,8"
materialDesign:ColorZoneAssist.Mode="PrimaryLight"> materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4"> <StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4"> <StackPanel Orientation="Horizontal" Margin="0,4">
<Button Content="开始采集" Command="{Binding StartSamplingCommand}" <TextBlock Text="波形文件" Style="{StaticResource ParamLabel}" />
Style="{StaticResource CmdBtn}" <ComboBox Width="210" Height="32" FontSize="12" Margin="4,0"
Background="#388E3C" Foreground="White" /> ItemsSource="{Binding SynthPresetOptions}"
<Button Content="停止采集" Command="{Binding StopSamplingCommand}" SelectedIndex="{Binding SelectedSynthPresetIndex}" />
<Button Content="载入" Command="{Binding LoadSynthPresetCommand}"
Style="{StaticResource CmdBtn}" /> Style="{StaticResource CmdBtn}" />
<Button Content="清空图表" Command="{Binding ClearChartsCommand}"
Style="{StaticResource CmdBtn}" />
<TextBlock VerticalAlignment="Center" FontSize="12" Margin="8,0,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="采集中…" />
<Setter Property="Foreground" Value="#4CAF50" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsSampling}" Value="False">
<Setter Property="Text" Value="采集已停止" />
<Setter Property="Foreground" Value="#9E9E9E" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4"> <StackPanel Orientation="Horizontal" Margin="0,4">
<CheckBox Content="采集频率" IsChecked="{Binding IsFrequencySamplingEnabled}" <TextBlock Text="名称" Style="{StaticResource ParamLabel}" />
VerticalAlignment="Center" Margin="4,0,10,0" <TextBox Style="{StaticResource NumInput}" Width="150"
ToolTip="频率一般无需持续采集;勾选后采样循环才读取频率并绘制到右侧频率曲线" /> ToolTip="另存为对话框的默认文件名(写入 D:\acp\源测试\合成波形);载入文件后自动回显当前文件名,保存不改动文件名"
<TextBlock Text="频率" Style="{StaticResource ParamLabel}" /> Text="{Binding SynthPresetName, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource MeasureBox}" <Button Content="保存" Command="{Binding SaveSynthPresetCommand}"
Text="{Binding FrequencyNow, Mode=OneWay, StringFormat={}{0:F2}}" /> Style="{StaticResource CmdBtn}"
<TextBlock Text="Hz" VerticalAlignment="Center" Margin="2,0,12,0" /> ToolTip="覆盖保存当前打开的波形文件(不改变文件名);未打开文件时按“另存为”新建"
<TextBlock Text="功率" Style="{StaticResource ParamLabel}" /> Background="#388E3C" Foreground="White" />
<TextBox Style="{StaticResource MeasureBox}" <Button Content="另存为" Command="{Binding SaveSynthPresetAsCommand}"
Text="{Binding PowerNow, Mode=OneWay, StringFormat={}{0:F2}}" /> Style="{StaticResource CmdBtn}"
<TextBlock Text="W" VerticalAlignment="Center" Margin="2,0,0,0" /> ToolTip="弹出文件对话框选择 .json 位置另存;存入 D:\acp\源测试\合成波形 才会出现在下拉列表" />
<Button Content="删除" Command="{Binding DeleteSynthPresetCommand}"
Style="{StaticResource WarnBtn}"
ToolTip="删除下拉框当前选中的波形文件(带二次确认)" />
</StackPanel>
<TextBox Margin="4,0,4,2" FontSize="11" Foreground="#1565C0"
Height="40" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto" VerticalContentAlignment="Top"
ToolTip="波形说明(随文件一并保存)"
Text="{Binding SynthPresetNote, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="{Binding SynthStorageHint}"
FontSize="11" Foreground="#757575"
Margin="4,0,4,4" TextWrapping="Wrap" />
<Separator Margin="0,4" />
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="数据格式" Style="{StaticResource ParamLabel}" />
<ComboBox Width="120" Height="32" FontSize="12" Margin="4,0"
ToolTip="SYNT:COMP,VALUE=绝对值 / PERCENT=基频百分率,后缀 1/2/3 为三段波形"
ItemsSource="{Binding SynthComposeOptions}"
SelectedItem="{Binding SynthCompose}" />
<TextBlock Text="基频" Style="{StaticResource ParamLabel}" />
<ComboBox Width="70" Height="32" FontSize="12" Margin="4,0"
ToolTip="SYNT:FREQ,仅支持 50 / 60 Hz"
ItemsSource="{Binding SynthFrequencyOptions}"
SelectedItem="{Binding SynthFrequency}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="基波电压" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}"
ToolTip="SYNT:FUND,基波交流电压,单位 V,0.0 ~ 300.0"
Text="{Binding SynthFundamental, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="V" VerticalAlignment="Center" Margin="2,0,10,0" />
<TextBlock Text="直流" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}"
ToolTip="SYNT:DC,叠加直流电压,单位 V,-424.2 ~ 424.2"
Text="{Binding SynthDc, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="V" VerticalAlignment="Center" Margin="2,0,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="编辑相位" Style="{StaticResource ParamLabel}" />
<ComboBox Width="100" Height="32" FontSize="12" Margin="4,0"
ToolTip="当前编辑的相位(INST:NSEL 1/2/3),起始相位/谐波振幅/谐波相位作用于该相"
ItemsSource="{Binding ListPhaseOptions}"
SelectedIndex="{Binding SynthEditPhaseIndex}" />
<Button Content="复制当前相到三相(起始相位自动 ±120°)"
Command="{Binding CopySynthPhaseToThreeCommand}"
Style="{StaticResource CmdBtn}" Margin="8,0,0,0"
ToolTip="把当前相的起始相位/谐波振幅/谐波相位复制到另外两相:谐波内容相同,起始相位按 ±120° 偏移生成对称三相;需再点「下发并启动 SYNTH」写入仪器" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="起始相位" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}"
ToolTip="SYNT:SPH,起始相位角,单位 °,0.0 ~ 359.9(作用于上方选中的编辑相位)"
Text="{Binding SynthStartPhase, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="°" VerticalAlignment="Center" Margin="2,0,0,0" />
</StackPanel>
<TextBlock Text="谐波振幅(SYNT:AMPL,英文逗号分隔,首值=2次谐波,依次 3/4/5…最多 50 阶,留空不下发)"
FontSize="11" Foreground="#616161" Margin="4,4,4,2" TextWrapping="Wrap" />
<TextBox Style="{StaticResource NumInput}" Width="380" HorizontalAlignment="Left" Margin="4,0"
ToolTip="如 0,10,0,5 表示 2次=0、3次=10、4次=0、5次=5(VALUE 为绝对值 V,PERCENT 为基波百分率)"
Text="{Binding SynthAmplitudeText, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="谐波相位(SYNT:PHAS,英文逗号分隔,与振幅逐阶对应,单位 °,0~359.9,留空不下发)"
FontSize="11" Foreground="#616161" Margin="4,4,4,2" TextWrapping="Wrap" />
<TextBox Style="{StaticResource NumInput}" Width="380" HorizontalAlignment="Left" Margin="4,0"
ToolTip="如 0,180,90 与振幅序列逐阶对应"
Text="{Binding SynthPhaseText, UpdateSourceTrigger=PropertyChanged}" />
<Separator Margin="0,6" />
<StackPanel Orientation="Horizontal" Margin="0,4">
<Button Content="下发并启动 SYNTH" Command="{Binding DownloadSynthesisCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="三相独立下发(INST:EDIT EACH + INST:NSEL 逐相):每相谐波内容相同、起始相位各不相同(±120° 对称)"
Background="#388E3C" Foreground="White" />
<Button Content="停止 SYNTH" Command="{Binding SynthStopCommand}"
Style="{StaticResource WarnBtn}" />
<Button Content="重置谐波(49个0)" Command="{Binding ResetSynthHarmonicsCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="谐波范围 2~50 阶共 49 个值(首值对应 2 次):把谐波振幅/相位两框填成 49 个 0(仅改界面文本,需再点「下发并启动 SYNTH」才写入仪器)" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -380,69 +450,234 @@
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
<!-- ============ 右侧:三相电压 + 输出频率(横轴为采集经过时间 s) ============ --> <!-- ============ 右侧:示波器抓波面板 ============ -->
<Grid Grid.Column="1"> <ScrollViewer Grid.Column="1"
<Grid.RowDefinitions> VerticalScrollBarVisibility="Auto"
<RowDefinition Height="*" /> HorizontalScrollBarVisibility="Disabled">
<RowDefinition Height="*" /> <StackPanel>
</Grid.RowDefinitions>
<!-- 三相电压 --> <GroupBox Header="示波器屏幕抓图(TektronixMSO)"
<Border Grid.Row="0" Background="White" BorderBrush="#DDD" BorderThickness="1" Margin="0,0,0,4"> materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<Grid> <StackPanel Margin="4">
<Grid.RowDefinitions> <!-- 示波器连接状态 -->
<RowDefinition Height="Auto" /> <StackPanel Orientation="Horizontal" Margin="0,2,0,4">
<RowDefinition Height="*" /> <Border Width="10" Height="10" CornerRadius="5" Margin="0,0,6,0">
</Grid.RowDefinitions> <Border.Style>
<StackPanel Orientation="Horizontal" Margin="10,6"> <Style TargetType="Border">
<TextBlock Text="三相电压 (V)" FontWeight="Bold" FontSize="13" <Setter Property="Background" Value="#F44336" />
VerticalAlignment="Center" Margin="0,0,16,0" /> <Style.Triggers>
<TextBlock Text="A" FontSize="12" Foreground="#E53935" VerticalAlignment="Center" /> <DataTrigger Binding="{Binding IsScopeConnected}" Value="True">
<TextBlock Text="{Binding VoltageA, StringFormat={}{0:F1}}" FontSize="12" <Setter Property="Background" Value="#4CAF50" />
FontWeight="Bold" VerticalAlignment="Center" Margin="3,0,14,0" /> </DataTrigger>
<TextBlock Text="B" FontSize="12" Foreground="#43A047" VerticalAlignment="Center" /> </Style.Triggers>
<TextBlock Text="{Binding VoltageB, StringFormat={}{0:F1}}" FontSize="12" </Style>
FontWeight="Bold" VerticalAlignment="Center" Margin="3,0,14,0" /> </Border.Style>
<TextBlock Text="C" FontSize="12" Foreground="#1E88E5" VerticalAlignment="Center" /> </Border>
<TextBlock Text="{Binding VoltageC, StringFormat={}{0:F1}}" FontSize="12" <TextBlock Text="{Binding ScopeName}"
FontWeight="Bold" VerticalAlignment="Center" Margin="3,0,0,0" /> FontSize="12" Foreground="#616161"
</StackPanel> VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<oxy:PlotView Grid.Row="1" Model="{Binding VoltagePlot}" </StackPanel>
Background="Transparent" Margin="4,0,4,4" />
</Grid>
</Border>
<!-- 输出频率(受左侧“采集频率”开关控制,一般不采集) --> <!-- 波形图片存储位置 -->
<Border Grid.Row="1" Background="White" BorderBrush="#DDD" BorderThickness="1"> <TextBlock Text="波形图片存储位置"
<Grid> FontSize="11" Foreground="#616161" Margin="4,0,4,2" />
<Grid.RowDefinitions> <Grid Margin="0,0,0,2">
<RowDefinition Height="Auto" /> <Grid.ColumnDefinitions>
<RowDefinition Height="*" /> <ColumnDefinition Width="*" />
</Grid.RowDefinitions> <ColumnDefinition Width="Auto" />
<StackPanel Orientation="Horizontal" Margin="10,6"> </Grid.ColumnDefinitions>
<TextBlock Text="输出频率 (Hz)" FontWeight="Bold" FontSize="13" <TextBox Style="{StaticResource NumInput}" Width="Auto"
VerticalAlignment="Center" Margin="0,0,16,0" /> ToolTip="抓取的屏幕图片 PNG 保存目录;也可直接在框内修改,抓图/浏览时自动记住"
<TextBlock Text="{Binding FrequencyNow, StringFormat={}{0:F2}}" FontSize="12" Text="{Binding ScopeImageDir, UpdateSourceTrigger=PropertyChanged}" />
FontWeight="Bold" Foreground="#8E24AA" VerticalAlignment="Center" Margin="3,0,14,0" /> <Button Grid.Column="1" Content="浏览…" Command="{Binding BrowseScopeImageDirCommand}"
<TextBlock FontSize="11" Foreground="#4CAF50" VerticalAlignment="Center"> Style="{StaticResource CmdBtn}" Margin="4,0,0,0"
<TextBlock.Style> ToolTip="弹出文件夹选择对话框选择存储位置" />
<Style TargetType="TextBlock"> </Grid>
<Setter Property="Text" Value="频率采集中" /> <Button Content="抓取示波器屏幕图片" Command="{Binding CaptureScopeImageCommand}"
<Style.Triggers> Style="{StaticResource CmdBtn}"
<DataTrigger Binding="{Binding IsFrequencySamplingEnabled}" Value="False"> Background="#388E3C" Foreground="White"
<Setter Property="Text" Value="频率采集未开启(在左侧“数据采集”勾选后才会绘制)" /> HorizontalAlignment="Left" Margin="0,4"
<Setter Property="Foreground" Value="#9E9E9E" /> ToolTip="抓取示波器当前屏幕保存为 PNG(SAVe:IMAGe),仅保存文件;查看波形图请点下方 LIST / SYNTHESIS" />
</DataTrigger>
</Style.Triggers> <!-- 波形截图:按当前载入配置文件的文件名(去 .json 后缀)查找同名 .png -->
</Style> <StackPanel Orientation="Horizontal" Margin="0,4,0,0">
</TextBlock.Style> <TextBlock Text="波形图" Style="{StaticResource ParamLabel}" />
</TextBlock> <Button Content="LIST" Command="{Binding ShowListWaveImageCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="取当前载入的 LIST 测试项文件名(去 .json 后缀),在波形图片存储位置查找同名 .png 并显示;找不到不显示图片" />
<Button Content="SYNTHESIS" Command="{Binding ShowSynthWaveImageCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="取当前载入的 SYNTHESIS 波形文件名(去 .json 后缀),在波形图片存储位置查找同名 .png 并显示;找不到不显示图片" />
</StackPanel>
<!-- 波形图片显示 -->
<Border BorderBrush="#DDDDDD" BorderThickness="1"
Background="#FFFFFF" CornerRadius="2" Margin="0,4,0,0">
<Grid Height="230">
<Image Source="{Binding ScopeImage}" Stretch="Uniform" Margin="4"
Visibility="{Binding HasScopeImage, Converter={StaticResource BoolToVis}}" />
<TextBlock Text="暂无波形图片&#x0a;点 LIST / SYNTHESIS 查看对应波形图"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="#BDBDBD" FontSize="13" TextAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding HasScopeImage}" Value="True">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</Border>
<TextBlock Text="{Binding ScopeImageHint}"
FontSize="11" Foreground="#757575"
Margin="4,4,4,2" TextWrapping="Wrap" />
</StackPanel> </StackPanel>
<oxy:PlotView Grid.Row="1" Model="{Binding FrequencyPlot}" </GroupBox>
Background="Transparent" Margin="4,0,4,4" />
</Grid> <!-- 采集控制:运行/停止采集,定格画面后抓图最稳定 -->
</Border> <GroupBox Header="采集控制" Margin="0,8,0,0"
</Grid> materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4">
<Button Content="运行 (RUN)" Command="{Binding ScopeAcquireRunCommand}"
Style="{StaticResource CmdBtn}"
Background="#388E3C" Foreground="White" />
<Button Content="停止 (STOP)" Command="{Binding ScopeAcquireStopCommand}"
Style="{StaticResource WarnBtn}"
ToolTip="停止采集定格当前画面,再点「抓取示波器屏幕图片」即可截到稳定波形" />
<Button Content="查询状态" Command="{Binding QueryAcquireStateCommand}"
Style="{StaticResource CmdBtn}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="采集模式" Style="{StaticResource ParamLabel}" />
<ComboBox Width="120" Height="32" FontSize="12" Margin="4,0"
ToolTip="ACQuire:MODe,采样/峰值检测/高分辨率/平均/包络"
ItemsSource="{Binding ScopeAcquireModeOptions}"
SelectedItem="{Binding ScopeAcquireMode}" />
<Button Content="应用" Command="{Binding ApplyAcquireModeCommand}"
Style="{StaticResource CmdBtn}" />
</StackPanel>
</StackPanel>
</GroupBox>
<!-- 水平系统 -->
<GroupBox Header="水平系统" Margin="0,8,0,0"
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="刻度(s/Div)" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="90"
ToolTip="HORizontal:SCAle,每格时间,单位 s,如 0.001"
Text="{Binding ScopeTimeScale, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="设置" Command="{Binding SetScopeTimeScaleCommand}"
Style="{StaticResource CmdBtn}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="记录长度" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="90"
ToolTip="HORizontal:RECOrdlength,采样点数,如 6250000"
Text="{Binding ScopeRecordLength, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="设置" Command="{Binding SetRecordLengthCommand}"
Style="{StaticResource CmdBtn}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<Button Content="查询采样率" Command="{Binding QuerySampleRateCommand}"
Style="{StaticResource CmdBtn}" />
<TextBox Style="{StaticResource MeasureBox}" Width="150"
Text="{Binding SampleRateText, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</GroupBox>
<!-- 通道设置 -->
<GroupBox Header="通道设置" Margin="0,8,0,0"
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="通道" Style="{StaticResource ParamLabel}" />
<ComboBox Width="80" Height="32" FontSize="12" Margin="4,0"
ItemsSource="{Binding ScopeChannelOptions}"
SelectedIndex="{Binding SelectedScopeChannelIndex}" />
<Button Content="显示开" Command="{Binding ChannelDisplayOnCommand}"
Style="{StaticResource CmdBtn}" />
<Button Content="显示关" Command="{Binding ChannelDisplayOffCommand}"
Style="{StaticResource CmdBtn}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="刻度(V/Div)" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="90"
ToolTip="CH:SCAle,每格电压,单位 V,如 100"
Text="{Binding ChannelScale, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="设置" Command="{Binding SetChannelScaleCommand}"
Style="{StaticResource CmdBtn}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="位置(Div)" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="90"
ToolTip="CH:POSition,垂直位置偏移,单位格,如 0"
Text="{Binding ChannelPosition, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="设置" Command="{Binding SetChannelPositionCommand}"
Style="{StaticResource CmdBtn}" />
</StackPanel>
</StackPanel>
</GroupBox>
<!-- 自动测量 -->
<GroupBox Header="自动测量" Margin="0,8,0,0"
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="测量项" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="52"
ToolTip="MEASUrement:MEASn 的编号 n"
Text="{Binding MeasurementNumber, UpdateSourceTrigger=PropertyChanged}" />
<ComboBox Width="110" Height="32" FontSize="12" Margin="4,0"
ToolTip="MEASUrement:TYPe,幅度/频率/平均/峰峰值/最大/最小"
ItemsSource="{Binding ScopeMeasTypeOptions}"
SelectedItem="{Binding MeasurementType}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="测量源" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="90"
ToolTip="MEASUrement:SOUrce1,如 CH1 / CH2 / MATH1"
Text="{Binding MeasurementSource, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="开启测量" Command="{Binding AddMeasurementCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="ADDNew 创建测量项并设置类型与源(4/5/6 系必须先动态创建)" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<Button Content="当前值" Command="{Binding QueryMeasCurrentCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="本次采集的测量平均值" />
<Button Content="全局均值" Command="{Binding QueryMeasMeanCommand}"
Style="{StaticResource CmdBtn}" />
<Button Content="全局最大" Command="{Binding QueryMeasMaxCommand}"
Style="{StaticResource CmdBtn}" />
</StackPanel>
<TextBox Style="{StaticResource MeasureBox}" Width="300" HorizontalAlignment="Left"
Margin="0,2" Text="{Binding MeasResultText, Mode=OneWay}" />
</StackPanel>
</GroupBox>
<!-- 设备辅助 -->
<GroupBox Header="设备辅助" Margin="0,8,0,0"
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Orientation="Horizontal" Margin="4,4">
<Button Content="查询 IDN" Command="{Binding QueryScopeIdnCommand}"
Style="{StaticResource CmdBtn}" />
<Button Content="清除错误" Command="{Binding ClearScopeErrorsCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="*CLS 清除所有状态寄存器与错误/事件队列" />
<Button Content="读取错误" Command="{Binding ReadScopeErrorsCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="ALLEv? 读取并清空示波器错误/事件队列,命令不生效时先看这里" />
</StackPanel>
</GroupBox>
</StackPanel>
</ScrollViewer>
</Grid> </Grid>
</Border> </Border>
</UserControl> </UserControl>
+633 -86
View File
@@ -22,8 +22,28 @@ namespace TSMasterCAN
public static event Action DisConnectEvent; public static event Action DisConnectEvent;
public static bool ConnectFlag { get; set; } = false; public static bool ConnectFlag { get; set; } = false;
/// <summary>初始化互斥锁:TSMaster 原生库每个进程只允许初始化一次</summary>
private static readonly object _initLock = new object();
/// <summary>初始化结果缓存(0 表示已成功初始化,无需再次初始化)</summary>
private static int _initResult = int.MinValue;
/// <summary> /// <summary>
/// 初始化CAN驱动 /// 驱动是否刚被释放、需要重新下发 DBC。
/// <para>
/// Release()/UnLoadDBC 成功后置 true,任一次 DBC 加载成功后置 false;
/// 上层(如 CANModule 点「初始化驱动」后)据此决定是否重载 DBC,
/// 避免拿残留的托管缓存去匹配已经清空的原生数据库(返回 93)。
/// </para>
/// </summary>
public static bool NeedReloadDbc { get; private set; }
/// <summary>
/// 初始化CAN驱动。
/// <para>
/// TSMaster 原生库每个进程只允许初始化一次:多工位(一拖三)各自调用时,
/// 重复 initialize_lib_tsmaster 会重建原生引擎,正在运行的循环发送表与回调队列随之失效,
/// 下一次发报文即触发原生访问冲突闪退。因此这里做进程级一次性保护,后续调用直接复用首次结果。
/// </para>
/// </summary> /// </summary>
/// <param name="ProjectName"></param> /// <param name="ProjectName"></param>
/// <param name="filePath"></param> /// <param name="filePath"></param>
@@ -31,46 +51,264 @@ namespace TSMasterCAN
[Browsable(false)] [Browsable(false)]
public static int Init(string ProjectName, string? filePath = null) public static int Init(string ProjectName, string? filePath = null)
{ {
if (string.IsNullOrEmpty(filePath)) try
{ {
return TsMasterApi.initialize_lib_tsmaster(ProjectName); lock (_initLock)
{
// 已成功初始化则直接复用:重复 initialize 会重建原生引擎,
// 正在循环发送的报文表随即失效。初始化失败则允许后续重试。
if (_initResult == 0) return 0;
_initResult = string.IsNullOrEmpty(filePath)
? TsMasterApi.initialize_lib_tsmaster(ProjectName)
: TsMasterApi.initialize_lib_tsmaster_with_project(ProjectName, Path.GetFullPath(filePath));
return _initResult;
}
} }
else catch (Exception ex)
{ {
return TsMasterApi.initialize_lib_tsmaster_with_project(ProjectName, Path.GetFullPath(filePath)); // 原生 API 异常统一转为返回码,不得向外传播导致进程闪退
Debug.WriteLine($"CAN.Init 异常:{ex.Message}");
return -1;
} }
} }
/// <summary> /// <summary>
/// 释放CAN驱动 /// 释放CAN驱动。
/// <para>释放后清除初始化缓存与回调注册状态,允许下一次 Init 重新初始化。</para>
/// </summary> /// </summary>
[Browsable(false)] [Browsable(false)]
public static void Release() public static void Release()
{ {
TsMasterApi.finalize_lib_tsmaster(); try
{
lock (_nativeSendLock)
{
lock (_initLock)
{
TsMasterApi.finalize_lib_tsmaster();
_initResult = int.MinValue;
// 驱动释放后 TSMaster 内部 DBC 数据库全部失效:
// 本地加载记录与托管报文缓存都要清空,并标记需要重新下发 DBC,
// 否则后续发送会拿残留缓存去匹配空数据库,返回 93(message not found in database)
_loadedDbcPool.Clear();
DBCParse.ClearMsgDatabase();
NeedReloadDbc = true;
ResetListenerRegistration();
}
}
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.Release 异常:{ex.Message}");
}
} }
public static int obj = 0; public static int obj = 0;
public static TCANFDQueueEvent_Win32 listener;
#region 一拖多引用计数
/// <summary>作用域名缺失时的兜底键</summary>
private const string DefaultScopeKey = "__default__";
/// <summary>
/// CAN 卡的作用域引用表(作用域名 → 引用计数)。
/// <para>
/// CAN 卡是一拖多共享硬件:多个工位共用同一个 TSMaster 实例,
/// 必须等最后一个工位销毁后才能停止循环报文、断开总线并释放原生驱动。
/// </para>
/// </summary>
private static readonly ConcurrentDictionary<string, int> _scopeRefs =
new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase);
/// <summary>当前仍持有 CAN 卡引用的作用域数量。</summary>
public static int ScopeRefCount => _scopeRefs.Count;
private static string NormalizeScopeKey(string? scopeName)
=> string.IsNullOrWhiteSpace(scopeName) ? DefaultScopeKey : scopeName!;
/// <summary>
/// 登记一个作用域对 CAN 卡的引用,需与 <see cref="ReleaseScope"/> 成对调用。
/// </summary>
/// <param name="scopeName">作用域名(通常为 SystemConfig.Title)</param>
public static void AcquireScope(string? scopeName)
{
var key = NormalizeScopeKey(scopeName);
_scopeRefs.AddOrUpdate(key, 1, (_, count) => count + 1);
Debug.WriteLine($"CAN.AcquireScope:{key},当前引用数 {_scopeRefs.Count}");
}
/// <summary>
/// 注销一个作用域对 CAN 卡的引用;最后一个引用移除时停止循环报文、断开总线并释放驱动。
/// </summary>
/// <param name="scopeName">作用域名(通常为 SystemConfig.Title)</param>
/// <returns>剩余引用数,0 表示已停止 CAN 卡</returns>
public static int ReleaseScope(string? scopeName)
{
var key = NormalizeScopeKey(scopeName);
while (true)
{
if (!_scopeRefs.TryGetValue(key, out var count)) return _scopeRefs.Count;
if (count > 1)
{
if (_scopeRefs.TryUpdate(key, count - 1, count)) return _scopeRefs.Count;
continue;
}
if (_scopeRefs.TryRemove(new KeyValuePair<string, int>(key, count)))
{
Debug.WriteLine($"CAN.ReleaseScope:{key},剩余引用数 {_scopeRefs.Count}");
if (!_scopeRefs.IsEmpty) return _scopeRefs.Count;
// 最后一个作用域已销毁:先停循环报文,再断开总线,最后释放原生驱动
DeleteCyclicMsgs();
DisConnect();
Release();
return 0;
}
}
}
#endregion
/// <summary>订阅者增删锁(仅保护列表本身,分发时不持锁,避免与原生回调线程死锁)</summary>
private static readonly object _listenerLock = new object();
/// <summary>
/// 发送类原生 API 串行锁:多工位共用同一个 TSMaster 实例,
/// 并发进入 tsapp_add_cyclic_msg_canfd / tsapp_transmit_canfd_async / tsapp_delete_cyclic_msgs 会破坏原生状态。
/// </summary>
private static readonly object _nativeSendLock = new object();
/// <summary>各工位注册的回调(在托管侧保根,原生库只持有唯一的 _dispatcher 函数指针)</summary>
private static readonly List<TCANFDQueueEvent_Win32> _listeners = new List<TCANFDQueueEvent_Win32>();
/// <summary>
/// 唯一注册到 TSMaster 的原生回调委托。
/// <para>
/// 必须由静态字段长期持有:原生库只保存函数指针,托管委托一旦被 GC 回收,
/// 原生线程回调到已释放的 thunk 就会直接访问冲突闪退(.NET 无法捕获)。
/// 多工位场景下原先每个工位各自覆盖 listener 静态字段,前一工位的委托随即失去根引用,
/// 这正是开两个工位发报文必闪退的原因。
/// </para>
/// </summary>
private static TCANFDQueueEvent_Win32? _dispatcher;
/// <summary>原生回调是否已注册(保证全进程只注册一次,避免同一个 obj 重复注册破坏 TSMaster 内部回调表)</summary>
private static bool _dispatcherRegistered = false;
/// <summary>
/// 注册 CANFD 报文回调(多工位安全)。
/// <para>首个订阅者触发原生注册,其余订阅者仅加入托管侧分发列表,从而避免重复注册与委托被回收。</para>
/// </summary>
[Browsable(false)] [Browsable(false)]
public static int RegisterListener(TCANFDQueueEvent_Win32 listenEvent) public static int RegisterListener(TCANFDQueueEvent_Win32 listenEvent)
{ {
listener = listenEvent; if (listenEvent == null) return -1;
var re = TsMasterApi.tsapp_register_event_canfd(ref obj, listener); try
Debug.Assert(re == 0); {
return re; lock (_listenerLock)
{
if (!_listeners.Contains(listenEvent)) _listeners.Add(listenEvent);
if (_dispatcherRegistered) return 0;
_dispatcher = DispatchCanFdEvent;
var re = TsMasterApi.tsapp_register_event_canfd(ref obj, _dispatcher);
Debug.Assert(re == 0);
if (re == 0) _dispatcherRegistered = true;
return re;
}
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.RegisterListener 异常:{ex.Message}");
return -1;
}
} }
/// <summary>
/// 注销 CANFD 报文回调。
/// <para>指定委托时只移除该订阅者;不指定时移除全部订阅者。所有订阅者移除后才反注册原生回调。</para>
/// </summary>
[Browsable(false)] [Browsable(false)]
public static int UnRegisterListener(TCANFDQueueEvent_Win32 listenEvent = null) public static int UnRegisterListener(TCANFDQueueEvent_Win32? listenEvent = null)
{ {
if (listenEvent is not null) try
{ {
listener = listenEvent; lock (_listenerLock)
{
if (listenEvent is not null)
{
_listeners.Remove(listenEvent);
}
else
{
_listeners.Clear();
}
if (_listeners.Count > 0 || !_dispatcherRegistered) return 0;
var dispatcher = _dispatcher;
if (dispatcher is null) return 0;
var re = TsMasterApi.tsapp_unregister_event_canfd(ref obj, dispatcher);
Debug.Assert(re == 0);
if (re == 0) ResetListenerRegistrationCore();
return re;
}
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.UnRegisterListener 异常:{ex.Message}");
return -1;
}
}
/// <summary>清除回调注册状态(调用方需自行持有 <see cref="_listenerLock"/>)</summary>
private static void ResetListenerRegistrationCore()
{
_dispatcherRegistered = false;
_dispatcher = null;
}
/// <summary>清除回调注册状态与全部订阅者</summary>
private static void ResetListenerRegistration()
{
lock (_listenerLock)
{
_listeners.Clear();
ResetListenerRegistrationCore();
}
}
/// <summary>
/// 原生 CANFD 回调统一入口:向所有已注册工位分发同一帧数据。
/// <para>单个订阅者异常必须就地隔离,异常一旦跨出原生边界即进程闪退。</para>
/// </summary>
private static void DispatchCanFdEvent(ref int AObj, ref TLIBCANFD AData)
{
TCANFDQueueEvent_Win32[] snapshot;
lock (_listenerLock)
{
snapshot = _listeners.ToArray();
}
foreach (var listener in snapshot)
{
try
{
listener(ref AObj, ref AData);
}
catch (Exception ex)
{
Debug.WriteLine($"CAN 回调订阅者异常(已隔离):{ex.Message}");
}
} }
var re = TsMasterApi.tsapp_unregister_event_canfd(ref obj, listener);
Debug.Assert(re == 0);
return re;
} }
/// <summary> /// <summary>
@@ -80,27 +318,44 @@ namespace TSMasterCAN
/// <returns>返回代码,0 表示成功,非 0 表示失败(可用 <see cref="GetErrorDescription"/> 查询错误说明)</returns> /// <returns>返回代码,0 表示成功,非 0 表示失败(可用 <see cref="GetErrorDescription"/> 查询错误说明)</returns>
public static int Connect() public static int Connect()
{ {
try
{
lock (_nativeSendLock)
{
// 多工位共用同一张 CAN 卡:任一工位已连接即复用,避免重复 tsapp_connect
if (ConnectFlag) return 0;
var re = TsMasterApi.tsapp_connect(); var re = TsMasterApi.tsapp_connect();
if (re == 0) if (re == 0)
{ {
re = TsMasterApi.tscom_can_rbs_start(); re = TsMasterApi.tscom_can_rbs_start();
if (re == 0) if (re == 0)
{ {
ConnectFlag = true; ConnectFlag = true;
Task.Run(() => ConnectEvent?.Invoke()); Task.Run(() =>
} {
else try { ConnectEvent?.Invoke(); }
{ catch (Exception ex) { Debug.WriteLine($"CAN.ConnectEvent 订阅者异常:{ex.Message}"); }
Debug.WriteLine($"CAN_RBS启动失败,错误代码:{re}"); });
}
else
{
Debug.WriteLine($"CAN_RBS启动失败,错误代码:{re}");
}
}
else
{
Debug.WriteLine($"CAN连接失败,错误代码:{re}");
return re;
}
return re;
} }
} }
else catch (Exception ex)
{ {
Debug.WriteLine($"CAN连接失败,错误代码:{re}"); Debug.WriteLine($"CAN.Connect 异常:{ex.Message}");
return re; return -1;
} }
return re;
} }
/// <summary> /// <summary>
@@ -109,20 +364,36 @@ namespace TSMasterCAN
/// <returns>返回代码,0 表示成功,非 0 表示失败(可用 <see cref="GetErrorDescription"/> 查询错误说明)</returns> /// <returns>返回代码,0 表示成功,非 0 表示失败(可用 <see cref="GetErrorDescription"/> 查询错误说明)</returns>
public static int DisConnect() public static int DisConnect()
{ {
var re = TsMasterApi.tsapp_disconnect(); try
//Debug.Assert(re == 0);
if (re == 0)
{ {
ConnectFlag = false; lock (_nativeSendLock)
Task.Run(() => DisConnectEvent?.Invoke()); {
// 已断开则直接返回:多工位场景下避免后续工位重复断开总线
if (!ConnectFlag) return 0;
var re = TsMasterApi.tsapp_disconnect();
if (re == 0)
{
ConnectFlag = false;
Task.Run(() =>
{
try { DisConnectEvent?.Invoke(); }
catch (Exception ex) { Debug.WriteLine($"CAN.DisConnectEvent 订阅者异常:{ex.Message}"); }
});
}
else
{
Debug.WriteLine($"断开CAN连接失败,错误代码:{re}");
}
return re;
}
} }
else catch (Exception ex)
{ {
Debug.WriteLine($"断开CAN连接失败,错误代码:{re}"); Debug.WriteLine($"CAN.DisConnect 异常:{ex.Message}");
return -1;
} }
return re;
} }
/// <summary> /// <summary>
@@ -137,16 +408,137 @@ namespace TSMasterCAN
{ {
databaseID = 0; databaseID = 0;
if (!Path.Exists(filePath)) return -1; if (!Path.Exists(filePath)) return -1;
var re = TsMasterApi.tsdb_load_can_db(Path.GetFullPath(filePath), string.Join(",", channel), ref databaseID); int re;
try
{
re = TsMasterApi.tsdb_load_can_db(Path.GetFullPath(filePath), string.Join(",", channel), ref databaseID);
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.LoadDBC 异常:{ex.Message}");
return -1;
}
if (re != 0)
{
// 加载失败时不再解析:databaseID 无效,解析只会把错误的报文混进托管缓存,
// 之后发送这些报文会得到 93(message not found in database)
Debug.WriteLine($"CAN.LoadDBC 失败:{filePath},返回代码 {re}");
return re;
}
foreach (var item in channel) foreach (var item in channel)
{ {
DBCParse.parse(databaseID, item); try
DBCParse.rbs_parse(databaseID, item); {
if (item < 0 || item >= DBCParse.MsgDatabase.Count) continue;
DBCParse.parse(databaseID, item);
DBCParse.rbs_parse(databaseID, item);
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.LoadDBC 解析失败(通道 {item}):{ex.Message}");
}
}
// 原生库已重新装载报文,托管缓存与原生库重新对齐
NeedReloadDbc = false;
return re;
}
/// <summary>
/// 已加载过的 DBC 记录(键 = 通道集合 + 绝对路径,值 = TSMaster 数据库 ID)。
/// <para>
/// 一拖多场景下多个工位共用同一张 CAN 卡:DBC 只需下发并解析一次,
/// 重复加载会让 DBCParse 把同一批报文重复追加进 MsgDatabase。
/// </para>
/// </summary>
private static readonly ConcurrentDictionary<string, uint> _loadedDbcPool =
new ConcurrentDictionary<string, uint>(StringComparer.OrdinalIgnoreCase);
private static string BuildDbcKey(string filePath, int[] channels)
=> $"{string.Join(",", channels.OrderBy(c => c))}|{Path.GetFullPath(filePath)}";
/// <summary>
/// 查询指定通道上的 DBC 是否已由其他工位加载过。
/// </summary>
/// <param name="filePath">DBC 文件路径</param>
/// <param name="channel">通道号数组</param>
[Browsable(false)]
public static bool IsDbcLoaded(string filePath, int[] channel)
{
if (!Path.Exists(filePath)) return false;
return _loadedDbcPool.ContainsKey(BuildDbcKey(filePath, channel));
}
/// <summary>
/// 加载DBC(同一通道 + 同一文件只加载一次)。
/// <para>
/// 供多工位自动加载使用:其他工位已加载过同一 DBC 时直接返回 0,
/// 不重复调用 tsdb_load_can_db,也不重复追加 DBCParse.MsgDatabase。
/// </para>
/// </summary>
/// <param name="filePath">DBC 文件路径</param>
/// <param name="channel">通道号数组</param>
/// <param name="databaseID">首次加载返回的数据库 ID;已加载过时返回首次的 ID</param>
/// <returns>返回代码,0 表示成功(含已加载跳过)</returns>
[Browsable(false)]
public static int LoadDBCOnce(string filePath, int[] channel, out uint databaseID)
{
databaseID = 0;
if (!Path.Exists(filePath)) return -1;
var key = BuildDbcKey(filePath, channel);
if (_loadedDbcPool.TryGetValue(key, out var loadedId))
{
databaseID = loadedId;
return 0;
}
var re = LoadDBC(filePath, channel, out databaseID);
if (re == 0) _loadedDbcPool[key] = databaseID;
return re;
}
/// <summary>清空 DBC 加载记录(卸载 DBC 或释放驱动时调用)。</summary>
[Browsable(false)]
public static void ClearDbcLoadRecords() => _loadedDbcPool.Clear();
/// <summary>
/// 加载DBC至单一通道
/// </summary>
/// <param name="filePath"></param>
/// <param name="channel"></param>
/// <returns></returns>
public static int LoadDBCToSingleChannel(string filePath, int channel)
{
uint databaseID = 0;
if (!Path.Exists(filePath)) return -1;
int re;
try
{
re = TsMasterApi.tsdb_load_can_db(Path.GetFullPath(filePath), string.Join(",", channel), ref databaseID);
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.LoadDBCToSingleChannel 异常:{ex.Message}");
return -1;
}
try
{
if (channel >= 0 && channel < DBCParse.MsgDatabase.Count)
{
DBCParse.parse(databaseID, channel);
DBCParse.rbs_parse(databaseID, channel);
}
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.LoadDBCToSingleChannel 解析失败(通道 {channel}):{ex.Message}");
} }
return re; return re;
} }
/// <summary> /// <summary>
/// 卸载DBC /// 卸载DBC
/// </summary> /// </summary>
@@ -155,13 +547,32 @@ namespace TSMasterCAN
[Browsable(false)] [Browsable(false)]
public static int UnLoadDBC(uint? databaseID = null) public static int UnLoadDBC(uint? databaseID = null)
{ {
if (databaseID == null) try
{ {
return TsMasterApi.tsdb_unload_can_dbs(); int re;
if (databaseID == null)
{
re = TsMasterApi.tsdb_unload_can_dbs();
}
else
{
re = TsMasterApi.tsdb_unload_can_db(databaseID.Value);
}
// TSMaster 内部数据库已卸载:本地加载记录与托管报文缓存必须同步失效,
// 否则再发送时会用残留的报文名去匹配空数据库,返回 93(message not found in database)
if (re == 0)
{
_loadedDbcPool.Clear();
DBCParse.ClearMsgDatabase();
NeedReloadDbc = true;
}
return re;
} }
else catch (Exception ex)
{ {
return TsMasterApi.tsdb_unload_can_db(databaseID.Value); Debug.WriteLine($"CAN.UnLoadDBC 异常:{ex.Message}");
return -1;
} }
} }
@@ -172,7 +583,15 @@ namespace TSMasterCAN
/// <returns>返回代码,0 表示成功,非 0 表示失败</returns> /// <returns>返回代码,0 表示成功,非 0 表示失败</returns>
public static int StartLogging(string filePath) public static int StartLogging(string filePath)
{ {
return TsMasterApi.tsapp_start_logging(Path.GetFullPath(filePath)); try
{
return TsMasterApi.tsapp_start_logging(Path.GetFullPath(filePath));
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.StartLogging 异常:{ex.Message}");
return -1;
}
} }
/// <summary> /// <summary>
@@ -181,7 +600,15 @@ namespace TSMasterCAN
/// <returns>返回代码,0 表示成功,非 0 表示失败</returns> /// <returns>返回代码,0 表示成功,非 0 表示失败</returns>
public static int StopLogging() public static int StopLogging()
{ {
return TsMasterApi.tsapp_stop_logging(); try
{
return TsMasterApi.tsapp_stop_logging();
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.StopLogging 异常:{ex.Message}");
return -1;
}
} }
/// <summary> /// <summary>
@@ -205,8 +632,16 @@ namespace TSMasterCAN
public static double GetSignalValue(byte channel, string AMsgName, string ASgnName) public static double GetSignalValue(byte channel, string AMsgName, string ASgnName)
{ {
double value = double.NaN; double value = double.NaN;
var find = DBCParse.MsgDatabase[channel].First(s => s.msg_name == AMsgName); var find = DBCParse.MsgDatabase[channel].FirstOrDefault(s => s.msg_name == AMsgName);
var re = TsMasterApi.tsdb_get_signal_value_canfd(ref find.ACANFD, AMsgName, ASgnName, ref value); if (find == null) return value;
try
{
TsMasterApi.tsdb_get_signal_value_canfd(ref find.ACANFD, AMsgName, ASgnName, ref value);
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.GetSignalValue 异常:{ex.Message}");
}
return value; return value;
} }
@@ -222,8 +657,17 @@ namespace TSMasterCAN
[Browsable(false)] [Browsable(false)]
public static int GetSignalValue(byte channel, string AMsgName, string ASgnName, ref double value) public static int GetSignalValue(byte channel, string AMsgName, string ASgnName, ref double value)
{ {
var find = DBCParse.MsgDatabase[channel].First(s => s.msg_name == AMsgName); var find = DBCParse.MsgDatabase[channel].FirstOrDefault(s => s.msg_name == AMsgName);
return TsMasterApi.tsdb_get_signal_value_canfd(ref find.ACANFD, AMsgName, ASgnName, ref value); if (find == null) return -1;
try
{
return TsMasterApi.tsdb_get_signal_value_canfd(ref find.ACANFD, AMsgName, ASgnName, ref value);
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.GetSignalValue 异常:{ex.Message}");
return -1;
}
} }
/// <summary> /// <summary>
@@ -254,21 +698,41 @@ namespace TSMasterCAN
/// <exception cref="Exception">信号编码写入报文失败时抛出</exception> /// <exception cref="Exception">信号编码写入报文失败时抛出</exception>
public static int SetSignalValue(byte channel, string AMsgName, string ASgnName, double AValue, bool isSend = false, float sendPeriod = 0) public static int SetSignalValue(byte channel, string AMsgName, string ASgnName, double AValue, bool isSend = false, float sendPeriod = 0)
{ {
var find = DBCParse.MsgDatabase[channel].First(s => s.msg_name == AMsgName); // MsgDatabase 只预分配 12 个通道,越界会让发送入口直接抛 IndexOutOfRange
if (channel >= DBCParse.MsgDatabase.Count) return -1;
var find = DBCParse.MsgDatabase[channel].FirstOrDefault(s => s.msg_name == AMsgName);
if (find == null) return -1;
find.ACANFD.FIdxChn = channel; find.ACANFD.FIdxChn = channel;
var re = TsMasterApi.tsdb_set_signal_value_canfd(ref find.ACANFD, AMsgName, ASgnName, AValue); var re = TsMasterApi.tsdb_set_signal_value_canfd(ref find.ACANFD, AMsgName, ASgnName, AValue);
Debug.Assert(re == 0); if (re != 0)
if (re != 0) throw new Exception($"设置报文失败!返回代码{re}"); {
if (re != 0) return re; // 93 = message not found in database:原生库在该通道上找不到这个报文名。
// 常见成因:驱动被释放 / DBC 被卸载后托管缓存没同步,或 DBC 实际加载在别的通道。
throw new Exception($"设置报文失败!返回代码{re}({GetErrorDescription(re)})");
}
if (isSend) if (isSend)
{ {
if (sendPeriod == 0) try
{ {
return TsMasterApi.tsapp_transmit_canfd_async(ref find.ACANFD); // 多工位共用同一个 TSMaster 实例,发送类原生 API 必须串行化,
// 并发调用会破坏原生内部循环发送表,表现为发报文瞬间闪退。
lock (_nativeSendLock)
{
if (sendPeriod == 0)
{
return TsMasterApi.tsapp_transmit_canfd_async(ref find.ACANFD);
}
else
{
return TsMasterApi.tsapp_add_cyclic_msg_canfd(ref find.ACANFD, sendPeriod);
}
}
} }
else catch (Exception ex)
{ {
return TsMasterApi.tsapp_add_cyclic_msg_canfd(ref find.ACANFD, sendPeriod); Debug.WriteLine($"CAN.SetSignalValue 发送异常:{ex.Message}");
return -1;
} }
} }
return 0; return 0;
@@ -284,15 +748,27 @@ namespace TSMasterCAN
/// <returns>返回代码,0 表示成功,非 0 表示失败</returns> /// <returns>返回代码,0 表示成功,非 0 表示失败</returns>
public static int SetSignalValue(APP_CHANNEL channel, string AMsgName, float sendPeriod = 0) public static int SetSignalValue(APP_CHANNEL channel, string AMsgName, float sendPeriod = 0)
{ {
var find = DBCParse.MsgDatabase[(byte)channel].First(s => s.msg_name == AMsgName); var find = DBCParse.MsgDatabase[(byte)channel].FirstOrDefault(s => s.msg_name == AMsgName);
if (find == null) return -1;
find.ACANFD.FIdxChn = (byte)channel; find.ACANFD.FIdxChn = (byte)channel;
if (sendPeriod == 0) try
{ {
return TsMasterApi.tsapp_transmit_canfd_async(ref find.ACANFD); lock (_nativeSendLock)
{
if (sendPeriod == 0)
{
return TsMasterApi.tsapp_transmit_canfd_async(ref find.ACANFD);
}
else
{
return TsMasterApi.tsapp_add_cyclic_msg_canfd(ref find.ACANFD, sendPeriod);
}
}
} }
else catch (Exception ex)
{ {
return TsMasterApi.tsapp_add_cyclic_msg_canfd(ref find.ACANFD, sendPeriod); Debug.WriteLine($"CAN.SetSignalValue 发送异常:{ex.Message}");
return -1;
} }
} }
@@ -303,7 +779,18 @@ namespace TSMasterCAN
[Browsable(false)] [Browsable(false)]
public static int SetMsg(TLIBCANFD msg) public static int SetMsg(TLIBCANFD msg)
{ {
return TsMasterApi.tsapp_transmit_canfd_async(ref msg); try
{
lock (_nativeSendLock)
{
return TsMasterApi.tsapp_transmit_canfd_async(ref msg);
}
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.SetMsg 发送异常:{ex.Message}");
return -1;
}
} }
/// <summary> /// <summary>
@@ -321,6 +808,23 @@ namespace TSMasterCAN
} }
} }
/// <summary>
/// 按帧 ID 构造并发送 CANFD 报文(不依赖 DBC 数据库与报文名),可选择单次发送或周期发送。
/// </summary>
/// <param name="channel">通道号</param>
/// <param name="ID">报文帧 ID</param>
/// <param name="bytes">报文数据数组(最多 64 字节,超出部分截断)</param>
/// <param name="sendPeriod">发送周期(毫秒):0 = 只发送一次;&gt;0 = 按该周期循环发送</param>
/// <param name="isExt">是否扩展帧(29 位 ID),默认标准帧</param>
/// <returns>返回代码,0 表示成功,非 0 表示失败(可用 <see cref="GetErrorDescription"/> 查询错误说明)</returns>
public static int SendMsgById(APP_CHANNEL channel, int ID, byte[] bytes, float sendPeriod = 0, bool isExt = false)
{
var data = new byte[64];
Array.Copy(bytes, data, Math.Min(bytes.Length, 64));
return SendMsg(channel, ID, true, isExt, false, (byte)Math.Min(bytes.Length, 64),
data, true, false, false, sendPeriod);
}
/// <summary> /// <summary>
/// 构造并发送自定义 CAN/CANFD 报文,可选择单次发送或周期发送,并可同步更新 DBC 数据库中同 ID 报文的数据。 /// 构造并发送自定义 CAN/CANFD 报文,可选择单次发送或周期发送,并可同步更新 DBC 数据库中同 ID 报文的数据。
/// </summary> /// </summary>
@@ -359,16 +863,27 @@ namespace TSMasterCAN
int length = Math.Min(ADataArray.Length, 64); int length = Math.Min(ADataArray.Length, 64);
Array.Copy(ADataArray, 0, send.FData, 0, length); Array.Copy(ADataArray, 0, send.FData, 0, length);
int re; int re;
if (sendPeriod == 0) try
{ {
re = TsMasterApi.tsapp_transmit_canfd_async(ref send); lock (_nativeSendLock)
{
if (sendPeriod == 0)
{
re = TsMasterApi.tsapp_transmit_canfd_async(ref send);
}
else
{
re = TsMasterApi.tsapp_add_cyclic_msg_canfd(ref send, sendPeriod);
}
}
} }
else catch (Exception ex)
{ {
re = TsMasterApi.tsapp_add_cyclic_msg_canfd(ref send, sendPeriod); Debug.WriteLine($"CAN.SendMsg 发送异常(通道 {(byte)AIdxChn} ID 0x{AID:X}):{ex.Message}");
return -1;
} }
if (isUpdateDBCDatabase) if (isUpdateDBCDatabase && (byte)AIdxChn < DBCParse.MsgDatabase.Count)
{ {
var find = DBCParse.MsgDatabase[(byte)AIdxChn].FirstOrDefault(s => s.ACANFD.FIdentifier == send.FIdentifier); var find = DBCParse.MsgDatabase[(byte)AIdxChn].FirstOrDefault(s => s.ACANFD.FIdentifier == send.FIdentifier);
if (find != null) if (find != null)
@@ -388,18 +903,42 @@ namespace TSMasterCAN
/// <returns>返回代码,0 表示成功,非 0 表示失败</returns> /// <returns>返回代码,0 表示成功,非 0 表示失败</returns>
public static int AddCyclicMsg(byte channel, string AMsgName, float sendPeriod) public static int AddCyclicMsg(byte channel, string AMsgName, float sendPeriod)
{ {
var find = DBCParse.MsgDatabase[channel].First(s => s.msg_name == AMsgName); var find = DBCParse.MsgDatabase[channel].FirstOrDefault(s => s.msg_name == AMsgName);
if (find == null) return -1;
find.ACANFD.FIdxChn = channel; find.ACANFD.FIdxChn = channel;
return TsMasterApi.tsapp_add_cyclic_msg_canfd(ref find.ACANFD, sendPeriod); try
{
lock (_nativeSendLock)
{
return TsMasterApi.tsapp_add_cyclic_msg_canfd(ref find.ACANFD, sendPeriod);
}
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.AddCyclicMsg 异常(通道 {channel} {AMsgName}):{ex.Message}");
return -1;
}
} }
/// <summary> /// <summary>
/// 清除所有循环发送的报文,停止全部周期发送任务。 /// 清除所有循环发送的报文,停止全部周期发送任务。
/// <para>注意:TSMaster 的循环发送表是全局的,此调用会同时清掉其他工位的循环发送。</para>
/// </summary> /// </summary>
/// <returns>返回代码,0 表示成功,非 0 表示失败</returns> /// <returns>返回代码,0 表示成功,非 0 表示失败</returns>
public static int DeleteCyclicMsgs() public static int DeleteCyclicMsgs()
{ {
return TsMasterApi.tsapp_delete_cyclic_msgs(); try
{
lock (_nativeSendLock)
{
return TsMasterApi.tsapp_delete_cyclic_msgs();
}
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.DeleteCyclicMsgs 异常:{ex.Message}");
return -1;
}
} }
/// <summary> /// <summary>
@@ -409,15 +948,23 @@ namespace TSMasterCAN
/// <returns>错误描述文本,未知错误代码时返回提示信息</returns> /// <returns>错误描述文本,未知错误代码时返回提示信息</returns>
public static string GetErrorDescription(int errorCode) public static string GetErrorDescription(int errorCode)
{ {
IntPtr ADesc = IntPtr.Zero; try
TsMasterApi.tsapp_get_error_description(errorCode, ref ADesc); {
IntPtr ADesc = IntPtr.Zero;
TsMasterApi.tsapp_get_error_description(errorCode, ref ADesc);
if (ADesc == IntPtr.Zero) return $"未知错误代码: {errorCode}"; if (ADesc == IntPtr.Zero) return $"未知错误代码: {errorCode}";
// 假设返回的是 ANSI 字符串(如 C 的 char*) // 假设返回的是 ANSI 字符串(如 C 的 char*)
string? description = Marshal.PtrToStringAnsi(ADesc); string? description = Marshal.PtrToStringAnsi(ADesc);
return description ?? $"未知错误代码: {errorCode}"; return description ?? $"未知错误代码: {errorCode}";
}
catch (Exception ex)
{
Debug.WriteLine($"CAN.GetErrorDescription 异常:{ex.Message}");
return $"未知错误代码: {errorCode}";
}
} }
} }
+13
View File
@@ -125,6 +125,19 @@ namespace TSMasterCAN
public static List<List<_Msg_>> MsgDatabase { get; set; } = public static List<List<_Msg_>> MsgDatabase { get; set; } =
[.. Enumerable.Range(0, 12).Select(_ => new List<_Msg_>())]; [.. Enumerable.Range(0, 12).Select(_ => new List<_Msg_>())];
public static can_network can_Network; public static can_network can_Network;
/// <summary>
/// 清空托管侧报文缓存。
/// <para>
/// TSMaster 原生数据库被卸载(tsdb_unload_can_dbs)或驱动被 finalize 后,
/// 这里的缓存必须同步清空,否则再次发送时会拿缓存里的报文去匹配空数据库,
/// tsdb_set_signal_value_canfd 会返回 93(message not found in database)。
/// </para>
/// </summary>
public static void ClearMsgDatabase()
{
foreach (var list in MsgDatabase) list.Clear();
}
public static string tsdb_get_can_db_info(uint ADatabaseId, int AType, int AIndex, int ASubIndex) public static string tsdb_get_can_db_info(uint ADatabaseId, int AType, int AIndex, int ASubIndex)
{ {
nint tmpInt = new nint(); nint tmpInt = new nint();
+1 -1
View File
@@ -14,7 +14,7 @@
<ItemGroup> <ItemGroup>
<Reference Include="Interop.TSMasterAPI"> <Reference Include="Interop.TSMasterAPI">
<HintPath>..\CanDriver\bin\Debug\net8.0\Interop.TSMasterAPI.dll</HintPath> <HintPath>Interop.TSMasterAPI.dll</HintPath>
</Reference> </Reference>
</ItemGroup> </ItemGroup>
+23
View File
@@ -0,0 +1,23 @@
using Prism;
using Prism.Ioc;
using TSMasterXCPModule.Views;
namespace TSMasterXCPModule
{
/// <summary>
/// TSMaster XCP 测试模块:基于同星 CAN(TSMaster)+ XCPCore 的 XCP 主站调试界面,
/// 支持 XCP 连接、内存读写与 DAQ 采集。
/// 通过左侧菜单"TSMaster XCP 测试"导航进入(仅 default 作用域可用)。
/// </summary>
public class TSMasterXCPModule : IModule
{
public void OnInitialized(IContainerProvider containerProvider)
{
}
public void RegisterTypes(IContainerRegistry containerRegistry)
{
containerRegistry.RegisterForNavigation<TSMasterXCPView>("TSMasterXCPView");
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TSMasterCAN\TSMasterCAN.csproj" />
<ProjectReference Include="..\TestingModule\TestingModule.csproj" />
<ProjectReference Include="..\UIShare\UIShare.csproj" />
<ProjectReference Include="..\XCPCore\XCPCore\XCPCore.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,728 @@
using Prism.Commands;
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Win32;
using TSMasterCAN;
using UIShare.ViewModelBase;
using XCPCore;
namespace TSMasterXCPModule.ViewModels
{
/// <summary>
/// TSMaster XCP 测试视图模型:总线连接(TSMaster)、XCP 会话(CONNECT/GET_STATUS)、
/// 内存读写(SET_MTA/UPLOAD/DOWNLOAD)与 DAQ 采集(FREE_DAQ/ALLOC_DAQ/WRITE_DAQ/START_STOP)。
/// 协议核心来自 XCPCore(<see cref="XcpMaster"/> + <see cref="XcpCanTransport"/>)。
/// </summary>
public class TSMasterXCPViewModel : NavigateViewModelBase, IDisposable
{
#region 字段
/// <summary>XCP CAN 传输层(XcpConnect 成功后创建)</summary>
private XcpCanTransport? _transport;
/// <summary>XCP 主站协议核心(XcpConnect 成功后创建)</summary>
private XcpMaster? _master;
/// <summary>日志缓冲(防止无限增长)</summary>
private readonly StringBuilder _logBuffer = new();
#endregion
#region 属性
private bool _isBusy;
/// <summary>是否正在执行异步操作(驱动 Busy 指示条)</summary>
public bool IsBusy { get => _isBusy; set { SetProperty(ref _isBusy, value); } }
private string _responseLog = string.Empty;
/// <summary>操作日志(时间戳前缀,只读展示)</summary>
public string ResponseLog { get => _responseLog; set => SetProperty(ref _responseLog, value); }
// ---------- TSMaster 总线 ----------
private string _tsMasterProjectPath = string.Empty;
/// <summary>TSMaster 工程文件路径(可空:空则初始化默认工程)</summary>
public string TSMasterProjectPath { get => _tsMasterProjectPath; set => SetProperty(ref _tsMasterProjectPath, value); }
private bool _isBusConnected;
/// <summary>TSMaster 总线是否已连接(tsapp_connect + rbs_start 成功)</summary>
public bool IsBusConnected { get => _isBusConnected; set => SetProperty(ref _isBusConnected, value); }
// ---------- A2L 描述文件 ----------
private string _a2lFilePath = string.Empty;
/// <summary>A2L 文件路径</summary>
public string A2lFilePath { get => _a2lFilePath; set => SetProperty(ref _a2lFilePath, value); }
private string _a2lSummaryText = "未加载 A2L 文件";
/// <summary>A2L 解析结果摘要</summary>
public string A2lSummaryText { get => _a2lSummaryText; set => SetProperty(ref _a2lSummaryText, value); }
/// <summary>解析出的测量量列表(选中后自动填入 DAQ 地址与长度)</summary>
public ObservableCollection<A2lMeasurement> A2lMeasurements { get; } = new ObservableCollection<A2lMeasurement>();
/// <summary>解析出的标定量列表(选中后自动填入内存读写地址与长度)</summary>
public ObservableCollection<A2lCharacteristic> A2lCharacteristics { get; } = new ObservableCollection<A2lCharacteristic>();
private A2lMeasurement? _selectedMeasurement;
/// <summary>当前选中的测量量:自动带入 DAQ 的 ODT 地址与长度</summary>
public A2lMeasurement? SelectedMeasurement
{
get => _selectedMeasurement;
set
{
if (!SetProperty(ref _selectedMeasurement, value) || value == null) return;
OdtAddressText = value.AddressText;
if (value.SizeBytes > 0) OdtLength = value.SizeBytes.ToString();
Log($"A2L 测量量 {value.Name}:地址 {value.AddressText},长度 {value.SizeText}(已填入 DAQ 配置)");
}
}
private A2lCharacteristic? _selectedCharacteristic;
/// <summary>当前选中的标定量:自动带入内存读写的地址与长度</summary>
public A2lCharacteristic? SelectedCharacteristic
{
get => _selectedCharacteristic;
set
{
if (!SetProperty(ref _selectedCharacteristic, value) || value == null) return;
MemAddressText = value.AddressText;
if (value.SizeBytes > 0 && value.SizeBytes <= 200) MemReadLength = value.SizeBytes.ToString();
Log($"A2L 标定量 {value.Name}({value.CharacteristicType}):地址 {value.AddressText},长度 {value.SizeText}(已填入内存读写)");
}
}
// ---------- XCP 连接配置 ----------
private string _canChannel = "0";
/// <summary>CAN 通道号(0 起始)</summary>
public string CanChannel { get => _canChannel; set => SetProperty(ref _canChannel, value); }
private string _croIdText = "0x7E0";
/// <summary>CRO CAN ID(命令发送,hex)</summary>
public string CroIdText { get => _croIdText; set => SetProperty(ref _croIdText, value); }
private string _dtoIdText = "0x7E8";
/// <summary>DTO/RES CAN ID(响应接收,hex)</summary>
public string DtoIdText { get => _dtoIdText; set => SetProperty(ref _dtoIdText, value); }
private bool _isExtendedFrame;
/// <summary>是否扩展帧(29 位 ID)</summary>
public bool IsExtendedFrame { get => _isExtendedFrame; set => SetProperty(ref _isExtendedFrame, value); }
private bool _isCanFd;
/// <summary>是否 CAN FD 帧</summary>
public bool IsCanFd { get => _isCanFd; set => SetProperty(ref _isCanFd, value); }
private string _commandTimeoutMs = "1000";
/// <summary>XCP 命令响应超时(毫秒)</summary>
public string CommandTimeoutMs { get => _commandTimeoutMs; set => SetProperty(ref _commandTimeoutMs, value); }
private bool _isConnected;
/// <summary>XCP 逻辑连接是否建立(CONNECT 成功)</summary>
public bool IsConnected { get => _isConnected; set => SetProperty(ref _isConnected, value); }
private string _slaveInfoText = "未连接";
/// <summary>从站能力信息(CONNECT 响应解析)</summary>
public string SlaveInfoText { get => _slaveInfoText; set => SetProperty(ref _slaveInfoText, value); }
// ---------- 内存读写 ----------
private string _memAddressText = "0x00000000";
/// <summary>内存地址(hex)</summary>
public string MemAddressText { get => _memAddressText; set => SetProperty(ref _memAddressText, value); }
private string _memReadLength = "8";
/// <summary>读取长度(字节)</summary>
public string MemReadLength { get => _memReadLength; set => SetProperty(ref _memReadLength, value); }
private string _memWriteDataText = string.Empty;
/// <summary>写入数据(hex 字节序列)</summary>
public string MemWriteDataText { get => _memWriteDataText; set => SetProperty(ref _memWriteDataText, value); }
private string _readResultText = string.Empty;
/// <summary>最近一次读取结果(hex)</summary>
public string ReadResultText { get => _readResultText; set => SetProperty(ref _readResultText, value); }
// ---------- DAQ 采集 ----------
private string _daqListNumber = "0";
/// <summary>DAQ 列表编号</summary>
public string DaqListNumber { get => _daqListNumber; set => SetProperty(ref _daqListNumber, value); }
private string _eventChannel = "0";
/// <summary>DAQ 事件通道编号</summary>
public string EventChannel { get => _eventChannel; set => SetProperty(ref _eventChannel, value); }
private string _prescaler = "1";
/// <summary>DAQ 预分频(每 N 个事件采集一次)</summary>
public string Prescaler { get => _prescaler; set => SetProperty(ref _prescaler, value); }
private string _odtAddressText = "0x00000000";
/// <summary>ODT 条目地址(hex,信号在从站内存中的地址)</summary>
public string OdtAddressText { get => _odtAddressText; set => SetProperty(ref _odtAddressText, value); }
private string _odtLength = "4";
/// <summary>ODT 条目长度(字节)</summary>
public string OdtLength { get => _odtLength; set => SetProperty(ref _odtLength, value); }
private string _latestDtoText = string.Empty;
/// <summary>最近收到的 DTO 帧(hex)</summary>
public string LatestDtoText { get => _latestDtoText; set => SetProperty(ref _latestDtoText, value); }
private int _dtoFrameCount;
/// <summary>已接收 DTO 帧计数</summary>
public int DtoFrameCount { get => _dtoFrameCount; set => SetProperty(ref _dtoFrameCount, value); }
#endregion
#region 命令
/// <summary>浏览并加载 A2L 描述文件</summary>
public DelegateCommand BrowseA2lCommand { get; }
/// <summary>按当前路径重新解析 A2L 描述文件</summary>
public DelegateCommand ReloadA2lCommand { get; }
/// <summary>初始化 TSMaster 驱动(可选加载工程)</summary>
public DelegateCommand InitDriverCommand { get; }
/// <summary>释放 TSMaster 驱动</summary>
public DelegateCommand ReleaseDriverCommand { get; }
/// <summary>连接 TSMaster CAN 总线</summary>
public DelegateCommand ConnectBusCommand { get; }
/// <summary>断开 TSMaster CAN 总线</summary>
public DelegateCommand DisconnectBusCommand { get; }
/// <summary>建立 XCP 逻辑连接(CONNECT)</summary>
public DelegateCommand XcpConnectCommand { get; }
/// <summary>断开 XCP 逻辑连接(DISCONNECT + 传输层关闭)</summary>
public DelegateCommand XcpDisconnectCommand { get; }
/// <summary>查询从站状态(GET_STATUS)</summary>
public DelegateCommand GetStatusCommand { get; }
/// <summary>读取内存(SET_MTA + UPLOAD)</summary>
public DelegateCommand ReadMemoryCommand { get; }
/// <summary>写入内存(SET_MTA + DOWNLOAD)</summary>
public DelegateCommand WriteMemoryCommand { get; }
/// <summary>配置 DAQ 列表(FREE_DAQ → ALLOC_DAQ → SET_DAQ_PTR/WRITE_DAQ → SET_DAQ_LIST_MODE)</summary>
public DelegateCommand ConfigureDaqCommand { get; }
/// <summary>启动 DAQ 采集</summary>
public DelegateCommand StartDaqCommand { get; }
/// <summary>停止 DAQ 采集</summary>
public DelegateCommand StopDaqCommand { get; }
/// <summary>接收一帧 DTO 数据</summary>
public DelegateCommand ReceiveDtoCommand { get; }
/// <summary>清空日志</summary>
public DelegateCommand ClearLogCommand { get; }
#endregion
/// <summary>创建视图模型</summary>
public TSMasterXCPViewModel(IContainerProvider containerProvider) : base(containerProvider)
{
BrowseA2lCommand = new DelegateCommand(BrowseA2l);
ReloadA2lCommand = new DelegateCommand(ReloadA2l);
InitDriverCommand = new DelegateCommand(() => RunGuarded(InitDriverAsync, "初始化驱动"));
ReleaseDriverCommand = new DelegateCommand(() => RunGuarded(ReleaseDriverAsync, "释放驱动"));
ConnectBusCommand = new DelegateCommand(() => RunGuarded(ConnectBusAsync, "连接总线"));
DisconnectBusCommand = new DelegateCommand(() => RunGuarded(DisconnectBusAsync, "断开总线"));
XcpConnectCommand = new DelegateCommand(() => RunGuarded(XcpConnectAsync, "XCP 连接"));
XcpDisconnectCommand = new DelegateCommand(() => RunGuarded(XcpDisconnectAsync, "XCP 断开"));
GetStatusCommand = new DelegateCommand(() => RunGuarded(GetStatusAsync, "查询状态"));
ReadMemoryCommand = new DelegateCommand(() => RunGuarded(ReadMemoryAsync, "读取内存"));
WriteMemoryCommand = new DelegateCommand(() => RunGuarded(WriteMemoryAsync, "写入内存"));
ConfigureDaqCommand = new DelegateCommand(() => RunGuarded(ConfigureDaqAsync, "配置 DAQ"));
StartDaqCommand = new DelegateCommand(() => RunGuarded(StartDaqAsync, "启动 DAQ"));
StopDaqCommand = new DelegateCommand(() => RunGuarded(StopDaqAsync, "停止 DAQ"));
ReceiveDtoCommand = new DelegateCommand(() => RunGuarded(ReceiveDtoAsync, "接收 DTO"));
ClearLogCommand = new DelegateCommand(() =>
{
_logBuffer.Clear();
ResponseLog = string.Empty;
});
Log("TSMaster XCP 测试界面就绪。使用顺序:初始化驱动 → 连接总线 → XCP 连接 → 内存读写 / DAQ。");
}
#region 命令处理 —— A2L 描述文件
/// <summary>浏览选择 A2L 文件并解析</summary>
private void BrowseA2l()
{
var dialog = new OpenFileDialog
{
Title = "选择 A2L 描述文件",
Filter = "A2L 描述文件 (*.a2l)|*.a2l|所有文件 (*.*)|*.*",
CheckFileExists = true,
Multiselect = false
};
var currentDir = Path.GetDirectoryName(A2lFilePath);
if (!string.IsNullOrEmpty(currentDir) && Directory.Exists(currentDir))
dialog.InitialDirectory = currentDir;
if (dialog.ShowDialog() != true) return;
LoadA2l(dialog.FileName);
}
/// <summary>按当前路径重新解析 A2L</summary>
private void ReloadA2l()
{
var path = A2lFilePath?.Trim() ?? string.Empty;
if (path.Length == 0)
{
Log("[提示] 请先选择 A2L 文件(点击「浏览...」)。");
return;
}
LoadA2l(path);
}
/// <summary>解析并刷新测量量 / 标定量列表</summary>
private void LoadA2l(string filePath)
{
if (!File.Exists(filePath))
{
A2lSummaryText = "A2L 文件不存在";
Log($"[错误] A2L 文件不存在: {filePath}");
return;
}
try
{
var db = A2lParser.LoadFile(filePath);
A2lFilePath = filePath;
A2lMeasurements.Clear();
foreach (var item in db.Measurements) A2lMeasurements.Add(item);
A2lCharacteristics.Clear();
foreach (var item in db.Characteristics) A2lCharacteristics.Add(item);
SelectedMeasurement = null;
SelectedCharacteristic = null;
A2lSummaryText = $"{db.Summary} · {Path.GetFileName(filePath)}";
Log($"已加载 A2L:{filePath}({db.Summary})");
if (db.Measurements.Count == 0 && db.Characteristics.Count == 0)
Log("[警告] 未解析出可用条目,请确认该文件是 A2L(ASAM MCD-2 MC)描述文件。");
}
catch (Exception ex)
{
A2lMeasurements.Clear();
A2lCharacteristics.Clear();
A2lSummaryText = "A2L 解析失败";
Log($"[错误] 解析 A2L 失败:{ex.Message}");
}
}
#endregion
#region 命令处理 —— TSMaster 总线
private async Task InitDriverAsync()
{
var project = TSMasterProjectPath?.Trim() ?? string.Empty;
int re;
if (string.IsNullOrEmpty(project))
{
re = CAN.Init("TSMasterXCP");
Log("初始化 TSMaster 驱动(默认工程)...");
}
else
{
if (!File.Exists(project))
{
Log($"[错误] TSMaster 工程文件不存在: {project}");
return;
}
re = CAN.Init("TSMasterXCP", project);
Log($"初始化 TSMaster 驱动(工程: {project})...");
}
if (re != 0)
{
Log($"[错误] 驱动初始化失败,返回码 {re}:{CAN.GetErrorDescription(re)}");
return;
}
Log("TSMaster 驱动初始化成功。");
await Task.CompletedTask;
}
private async Task ReleaseDriverAsync()
{
if (_master != null)
await XcpDisconnectAsync();
CAN.Release();
IsBusConnected = false;
Log("TSMaster 驱动已释放。");
await Task.CompletedTask;
}
private async Task ConnectBusAsync()
{
if (!CAN.ConnectFlag)
{
Log("连接 TSMaster CAN 总线...");
int re = CAN.Connect();
if (re != 0)
{
Log($"[错误] 总线连接失败,返回码 {re}:{CAN.GetErrorDescription(re)}(请检查硬件通道映射)");
return;
}
}
IsBusConnected = true;
Log("TSMaster CAN 总线已连接。");
await Task.CompletedTask;
}
private async Task DisconnectBusAsync()
{
if (_master != null)
await XcpDisconnectAsync();
int re = CAN.DisConnect();
IsBusConnected = false;
Log(re == 0 ? "TSMaster CAN 总线已断开。" : $"[警告] 总线断开返回码 {re}:{CAN.GetErrorDescription(re)}");
await Task.CompletedTask;
}
#endregion
#region 命令处理 —— XCP 会话
private async Task XcpConnectAsync()
{
if (!CAN.ConnectFlag)
{
Log("[错误] TSMaster 总线未连接,请先执行「初始化驱动 → 连接总线」。");
return;
}
if (_master != null && _master.State != XcpSessionState.Disconnected)
{
Log("XCP 已处于连接状态,如需重连请先断开。");
return;
}
if (!TryGetChannel(out byte channel)) return;
if (!TryGetHexUint(CroIdText, "CRO ID", out uint croId)) return;
if (!TryGetHexUint(DtoIdText, "DTO ID", out uint dtoId)) return;
int timeoutMs = ParseInt(CommandTimeoutMs, 1000);
// 每次连接按当前界面参数重建传输层与主站
_transport = new XcpCanTransport(channel, croId, dtoId, IsExtendedFrame, IsCanFd, timeoutMs);
_master = new XcpMaster(_transport, new XcpConfig { CommandTimeoutMs = timeoutMs });
Log($"XCP CONNECT: CH={channel}, CRO=0x{croId:X}, DTO=0x{dtoId:X}, {(IsCanFd ? "CAN FD" : "经典 CAN")}...");
bool ok = await _master.ConnectAsync(0x00);
if (!ok)
{
Log("[错误] XCP CONNECT 失败(无有效响应,请检查从站上电、ID 配置与接线)。");
_master = null;
_transport?.Close();
_transport = null;
IsConnected = false;
return;
}
IsConnected = true;
var info = _master.SlaveInfo;
SlaveInfoText = $"MaxCto={info.MaxCtoSize} MaxDto={info.MaxDtoSize} 资源=0x{info.Resource:X2}"
+ $" 协议版本={info.ProtocolVersion} 传输层版本={info.TransportLayerVersion}";
Log($"XCP CONNECT 成功。{SlaveInfoText}");
}
private async Task XcpDisconnectAsync()
{
if (_master != null)
{
await _master.FullDisconnectAsync();
Log("XCP 已断开(DISCONNECT + 传输层关闭)。");
}
_master = null;
_transport = null;
IsConnected = false;
SlaveInfoText = "未连接";
}
private async Task GetStatusAsync()
{
if (!RequireMaster()) return;
var status = await _master!.GetStatusAsync();
if (status == null)
{
Log("[错误] GET_STATUS 无响应。");
return;
}
// 正响应: [FF][SESSION_STATUS][PROTECTION_STATUS][SEQ][DAQ_COUNT(2)][EVENT_COUNT(2)]
var sb = new StringBuilder("GET_STATUS: ");
sb.Append($"会话状态=0x{status[1]:X2}");
if (status.Length > 2) sb.Append($", 保护状态=0x{status[2]:X2}");
if (status.Length > 4) sb.Append($", 配置的 DAQ 列表数={(status[4] << 8) | status[5]}");
if (status.Length > 6) sb.Append($", 事件通道数={(status[6] << 8) | status[7]}");
Log(sb.ToString());
}
#endregion
#region 命令处理 —— 内存读写
private async Task ReadMemoryAsync()
{
if (!RequireMaster()) return;
if (!TryGetHexUint(MemAddressText, "地址", out uint address)) return;
int length = ParseInt(MemReadLength, 8);
if (length <= 0 || length > 200)
{
Log("[错误] 读取长度须在 1~200 字节之间。");
return;
}
var data = await _master!.ReadMemoryAsync(address, length);
if (data == null)
{
Log($"[错误] 读取内存 0x{address:X8} 失败。");
return;
}
ReadResultText = ToHexString(data);
Log($"读取 0x{address:X8} × {data.Length} 字节: {ReadResultText}");
}
private async Task WriteMemoryAsync()
{
if (!RequireMaster()) return;
if (!TryGetHexUint(MemAddressText, "地址", out uint address)) return;
byte[] data;
try
{
data = ParseHexBytes(MemWriteDataText);
}
catch (FormatException)
{
Log("[错误] 写入数据格式无效,请输入 hex 字节序列(如 FF 00 1A)。");
return;
}
if (data.Length == 0)
{
Log("[错误] 写入数据为空。");
return;
}
bool ok = await _master!.WriteMemoryAsync(address, data);
Log(ok
? $"写入 0x{address:X8} × {data.Length} 字节成功: {ToHexString(data)}"
: $"[错误] 写入内存 0x{address:X8} 失败。");
}
#endregion
#region 命令处理 —— DAQ 采集
private async Task ConfigureDaqAsync()
{
if (!RequireMaster()) return;
ushort daqList = (ushort)Math.Clamp(ParseInt(DaqListNumber, 0), 0, 0xFFFF);
ushort eventChannel = (ushort)Math.Clamp(ParseInt(EventChannel, 0), 0, 0xFFFF);
ushort prescaler = (ushort)Math.Clamp(ParseInt(Prescaler, 1), 1, 0xFFFF);
if (!TryGetHexUint(OdtAddressText, "ODT 地址", out uint address)) return;
int odtLen = ParseInt(OdtLength, 4);
if (odtLen <= 0 || odtLen > 64)
{
Log("[错误] ODT 条目长度须在 1~64 字节之间。");
return;
}
var config = new DaqListConfig
{
DaqListNumber = daqList,
EventChannel = eventChannel,
Prescaler = prescaler,
Mode = 0x00,
Enable = true,
OdEntries = { new OdEntry { Address = address, AddressExtension = 0, Length = (byte)odtLen, Name = $"ODT0_{address:X8}" } }
};
bool ok = await _master!.ConfigureDaqListAsync(config);
Log(ok
? $"DAQ 配置成功:列表 {daqList},事件通道 {eventChannel},预分频 {prescaler},条目 0x{address:X8} × {odtLen}B。"
: "[错误] DAQ 配置失败(FREE_DAQ/ALLOC_DAQ/WRITE_DAQ/SET_DAQ_LIST_MODE 某一步被从站拒绝)。");
}
private async Task StartDaqAsync()
{
if (!RequireMaster()) return;
ushort daqList = (ushort)Math.Clamp(ParseInt(DaqListNumber, 0), 0, 0xFFFF);
bool ok = await _master!.StartStopDaqAsync(true, daqList);
Log(ok ? $"DAQ 列表 {daqList} 已启动采集。" : $"[错误] 启动 DAQ 列表 {daqList} 失败。");
}
private async Task StopDaqAsync()
{
if (!RequireMaster()) return;
ushort daqList = (ushort)Math.Clamp(ParseInt(DaqListNumber, 0), 0, 0xFFFF);
bool ok = await _master!.StartStopDaqAsync(false, daqList);
Log(ok ? $"DAQ 列表 {daqList} 已停止。" : $"[错误] 停止 DAQ 列表 {daqList} 失败。");
}
private async Task ReceiveDtoAsync()
{
if (!RequireMaster()) return;
var dto = await _master!.ReceiveDtoAsync(500);
if (dto == null)
{
Log("500ms 内未收到 DTO 帧(确认 DAQ 是否已启动、DTO ID 是否正确)。");
return;
}
DtoFrameCount++;
LatestDtoText = ToHexString(dto);
Log($"DTO #{DtoFrameCount}: {LatestDtoText}");
}
#endregion
#region 通用辅助
/// <summary>统一命令入口:Busy 指示 + 异常捕获 + 日志</summary>
private async void RunGuarded(Func<Task> action, string actionName)
{
if (IsBusy) return;
IsBusy = true;
try
{
await action();
}
catch (TimeoutException tex)
{
Log($"[错误] {actionName} 超时:{tex.Message}");
}
catch (XcpProtocolException xex)
{
Log($"[错误] {actionName} 协议错误:{xex.Message}");
}
catch (InvalidOperationException ioex)
{
Log($"[错误] {actionName}:{ioex.Message}");
}
catch (Exception ex)
{
Log($"[错误] {actionName} 异常:{ex.Message}");
}
finally
{
IsBusy = false;
}
}
/// <summary>确认 XCP 主站已创建(界面校验)</summary>
private bool RequireMaster()
{
if (_master == null || _master.State == XcpSessionState.Disconnected)
{
Log("[错误] XCP 未连接,请先执行「XCP 连接」。");
return false;
}
return true;
}
/// <summary>解析 CAN 通道号(0 起始)</summary>
private bool TryGetChannel(out byte channel)
{
channel = 0;
if (!int.TryParse(CanChannel?.Trim(), out int value) || value < 0 || value > 255)
{
Log($"[错误] CAN 通道号无效: {CanChannel}(0~255)。");
return false;
}
channel = (byte)value;
return true;
}
/// <summary>解析 hex 整数:支持 0x 前缀 / 纯 hex(如 7E0 / 0x7E0)</summary>
private bool TryGetHexUint(string text, string fieldName, out uint value)
{
value = 0;
var cleaned = (text ?? string.Empty).Trim().Replace(" ", "");
if (cleaned.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
cleaned = cleaned[2..];
if (cleaned.Length == 0 || !uint.TryParse(cleaned,
System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out value))
{
Log($"[错误] {fieldName} 格式无效: {text}(应为 hex,如 0x7E0)。");
return false;
}
return true;
}
/// <summary>解析 hex 字节序列:FF 00 1A / FF,00,1A / 0xFF-0x00 均可</summary>
private static byte[] ParseHexBytes(string text)
{
var tokens = (text ?? string.Empty).Split(new[] { ' ', ',', '-', ';' }, StringSplitOptions.RemoveEmptyEntries);
var result = new byte[tokens.Length];
for (int i = 0; i < tokens.Length; i++)
{
var token = tokens[i];
if (token.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
token = token[2..];
result[i] = byte.Parse(token, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture);
}
return result;
}
/// <summary>字节数组转 hex 字符串(大写、空格分隔)</summary>
private static string ToHexString(byte[] data)
{
if (data == null || data.Length == 0) return string.Empty;
var sb = new StringBuilder(data.Length * 3);
foreach (var b in data)
sb.Append(b.ToString("X2")).Append(' ');
return sb.ToString().TrimEnd();
}
/// <summary>安全解析整数(失败用默认值)</summary>
private static int ParseInt(string? text, int defaultValue)
=> int.TryParse(text?.Trim(), out int value) ? value : defaultValue;
/// <summary>追加一行带时间戳的日志(超 200 行自动截断头部)</summary>
private void Log(string message)
{
var line = $"[{DateTime.Now:HH:mm:ss.fff}] {message}";
_logBuffer.AppendLine(line);
var text = _logBuffer.ToString();
var lines = text.Split('\n');
if (lines.Length > 200)
{
_logBuffer.Clear();
_logBuffer.Append(string.Join("\n", lines[^200..]));
}
ResponseLog = _logBuffer.ToString().TrimEnd();
}
#endregion
/// <summary>释放资源:断开 XCP 连接并关闭传输层</summary>
public void Dispose()
{
try
{
if (_master != null)
_master.FullDisconnectAsync().Wait(2000);
else
_transport?.Close();
}
catch { }
_master = null;
_transport = null;
}
}
}
@@ -0,0 +1,371 @@
<UserControl x:Class="TSMasterXCPModule.Views.TSMasterXCPView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
xmlns:prism="http://prismlibrary.com/"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:converters="clr-namespace:UIShare.Converters;assembly=UIShare"
prism:ViewModelLocator.AutoWireViewModel="True"
d:DesignHeight="1080"
d:DesignWidth="1920">
<UserControl.Resources>
<converters:BooleanToVisibilityConverter x:Key="BoolToVis" />
</UserControl.Resources>
<Border Background="#F5F7FA">
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ScrollViewer Grid.Column="0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Margin="0,0,8,0">
<StackPanel>
<!-- ============ 设备状态卡片 ============ -->
<materialDesign:Card Margin="0,0,0,8" Padding="12,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<materialDesign:PackIcon Kind="Connection"
Width="22" Height="22"
Foreground="#1565C0"
Margin="0,0,8,0"
VerticalAlignment="Center" />
<TextBlock Text="TSMaster XCP 测试 · XcpMaster"
FontSize="15" FontWeight="Bold"
VerticalAlignment="Center" />
<TextBlock FontSize="12" VerticalAlignment="Center" Margin="12,0,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="总线: 未连接" />
<Setter Property="Foreground" Value="#F44336" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsBusConnected}" Value="True">
<Setter Property="Text" Value="总线: 已连接" />
<Setter Property="Foreground" Value="#4CAF50" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="10" Height="10" CornerRadius="5" Margin="0,0,6,0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#F44336" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Background" Value="#4CAF50" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<TextBlock VerticalAlignment="Center" FontSize="12">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="XCP 未连接" />
<Setter Property="Foreground" Value="#F44336" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Text" Value="XCP 已连接" />
<Setter Property="Foreground" Value="#4CAF50" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<ProgressBar IsIndeterminate="True" Width="60" Height="4"
Margin="12,0,0,0"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVis}}" />
</StackPanel>
</Grid>
</materialDesign:Card>
<!-- ============ TSMaster 总线连接 ============ -->
<GroupBox Header="TSMaster 总线连接" Margin="0,0,0,8"
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="工程文件" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="360"
ToolTip="TSMaster 工程文件(.tscproj / .xcp 等全路径),留空则使用默认工程初始化"
Text="{Binding TSMasterProjectPath, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="初始化驱动" Command="{Binding InitDriverCommand}"
Style="{StaticResource CmdBtn}"
Background="#388E3C" Foreground="White" />
<Button Content="释放驱动" Command="{Binding ReleaseDriverCommand}"
Style="{StaticResource WarnBtn}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<Button Content="连接总线" Command="{Binding ConnectBusCommand}"
Style="{StaticResource CmdBtn}"
Background="#388E3C" Foreground="White"
ToolTip="tsapp_connect + 启动 RBS 仿真,XCP 收发前必须先连接总线" />
<Button Content="断开总线" Command="{Binding DisconnectBusCommand}"
Style="{StaticResource WarnBtn}" />
<TextBlock Text="{Binding IsBusConnected, StringFormat='总线状态: {0}'}"
VerticalAlignment="Center" Margin="12,0,0,0"
FontSize="12" Foreground="#616161" />
</StackPanel>
</StackPanel>
</GroupBox>
<!-- ============ A2L 描述文件 ============ -->
<GroupBox Header="A2L 描述文件(测量量 / 标定量)" Margin="0,0,0,8"
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="A2L文件" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="460"
ToolTip="ECU 描述文件(.a2l)。加载后可从下方列表选择测量量/标定量,自动带入地址与长度"
Text="{Binding A2lFilePath, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="浏览..." Command="{Binding BrowseA2lCommand}"
Style="{StaticResource CmdBtn}"
Background="#1565C0" Foreground="White"
ToolTip="选择 A2L 文件并解析" />
<Button Content="重新解析" Command="{Binding ReloadA2lCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="按当前路径重新解析(A2L 被工具重新生成后使用)" />
<TextBlock Text="{Binding A2lSummaryText}"
VerticalAlignment="Center" Margin="12,0,0,0"
FontSize="12" Foreground="#616161"
TextTrimming="CharacterEllipsis" MaxWidth="360" />
</StackPanel>
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="10" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Text="测量量 MEASUREMENT → DAQ 地址/长度"
FontSize="12" FontWeight="SemiBold"
Foreground="#1565C0" Margin="2,0,0,4" />
<ListBox Height="180"
ItemsSource="{Binding A2lMeasurements}"
SelectedItem="{Binding SelectedMeasurement}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ToolTip="{Binding Detail}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Name}" FontSize="12"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Text="{Binding AddressText}"
FontFamily="Consolas" FontSize="11"
Foreground="#616161" Margin="8,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding SizeText}"
FontSize="11" Foreground="#9E9E9E"
Margin="8,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<DockPanel Grid.Column="2">
<TextBlock DockPanel.Dock="Top" Text="标定量 CHARACTERISTIC → 内存读写"
FontSize="12" FontWeight="SemiBold"
Foreground="#1565C0" Margin="2,0,0,4" />
<ListBox Height="180"
ItemsSource="{Binding A2lCharacteristics}"
SelectedItem="{Binding SelectedCharacteristic}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ToolTip="{Binding Detail}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Name}" FontSize="12"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Text="{Binding CharacteristicType}"
FontSize="11" Foreground="#9E9E9E"
Margin="8,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding AddressText}"
FontFamily="Consolas" FontSize="11"
Foreground="#616161" Margin="8,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Grid>
</StackPanel>
</GroupBox>
<!-- ============ XCP 连接配置 ============ -->
<GroupBox Header="XCP 连接配置" Margin="0,0,0,8"
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="CAN通道" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="60"
ToolTip="TSMaster 通道号,0 起始"
Text="{Binding CanChannel, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="CRO ID" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="90"
ToolTip="命令发送 CAN ID(hex),如 0x7E0"
Text="{Binding CroIdText, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="DTO ID" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="90"
ToolTip="响应/DAQ 接收 CAN ID(hex),如 0x7E8"
Text="{Binding DtoIdText, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="超时(ms)" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="70"
ToolTip="XCP 命令响应超时时间"
Text="{Binding CommandTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="帧类型" Style="{StaticResource ParamLabel}" />
<CheckBox Content="扩展帧 (29位ID)" IsChecked="{Binding IsExtendedFrame}"
VerticalAlignment="Center" Margin="4,0" />
<CheckBox Content="CAN FD" IsChecked="{Binding IsCanFd}"
VerticalAlignment="Center" Margin="4,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<Button Content="XCP 连接" Command="{Binding XcpConnectCommand}"
Style="{StaticResource CmdBtn}"
Background="#388E3C" Foreground="White"
ToolTip="CONNECT 0x00:建立 XCP 逻辑连接(先连总线)" />
<Button Content="XCP 断开" Command="{Binding XcpDisconnectCommand}"
Style="{StaticResource WarnBtn}" />
<Button Content="查询状态" Command="{Binding GetStatusCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="GET_STATUS:会话状态 / 已配置 DAQ 列表数 / 事件通道数" />
</StackPanel>
<TextBlock Text="{Binding SlaveInfoText}"
FontSize="12" Foreground="#1565C0"
Margin="4,4,4,0" TextWrapping="Wrap" />
</StackPanel>
</GroupBox>
<!-- ============ 内存读写 ============ -->
<GroupBox Header="内存读写(SET_MTA / UPLOAD / DOWNLOAD)" Margin="0,0,0,8"
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="地址(hex)" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="110"
ToolTip="从站内存地址,如 0x20000000"
Text="{Binding MemAddressText, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="读取长度" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="60"
ToolTip="字节数,1~200"
Text="{Binding MemReadLength, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="读取内存" Command="{Binding ReadMemoryCommand}"
Style="{StaticResource CmdBtn}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,2">
<TextBlock Text="读取结果" Style="{StaticResource ParamLabel}" />
<TextBox IsReadOnly="True" Width="420" Height="30" FontSize="12"
FontFamily="Consolas" Background="#FAFAFA"
VerticalContentAlignment="Center" Margin="4,0"
Text="{Binding ReadResultText, Mode=OneWay}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="写入数据" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="300"
ToolTip="hex 字节序列(空格/逗号分隔),如 01 A2 FF 00"
Text="{Binding MemWriteDataText, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="写入内存" Command="{Binding WriteMemoryCommand}"
Style="{StaticResource CmdBtn}"
Background="#388E3C" Foreground="White" />
</StackPanel>
</StackPanel>
</GroupBox>
<!-- ============ DAQ 采集 ============ -->
<GroupBox Header="DAQ 采集(FREE_DAQ / ALLOC_DAQ / WRITE_DAQ / START_STOP)" Margin="0,0,0,8"
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel Margin="4">
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="DAQ列表号" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="60"
ToolTip="DAQ 列表编号(十进制,0 起始)"
Text="{Binding DaqListNumber, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="事件通道" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="60"
ToolTip="事件通道编号(十进制),决定采集触发源"
Text="{Binding EventChannel, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="预分频" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="60"
ToolTip="每 N 个事件采集一次(1=每个事件都采)"
Text="{Binding Prescaler, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4">
<TextBlock Text="ODT地址" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="110"
ToolTip="要采集的信号在从站内存中的地址(hex)"
Text="{Binding OdtAddressText, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="长度" Style="{StaticResource ParamLabel}" />
<TextBox Style="{StaticResource NumInput}" Width="50"
ToolTip="信号长度(字节,1~64)"
Text="{Binding OdtLength, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="配置 DAQ" Command="{Binding ConfigureDaqCommand}"
Style="{StaticResource CmdBtn}"
Background="#388E3C" Foreground="White"
ToolTip="释放并重新分配 DAQ 资源后写入 ODT 条目(会清空从站已有 DAQ 配置)" />
<Button Content="启动 DAQ" Command="{Binding StartDaqCommand}"
Style="{StaticResource CmdBtn}" />
<Button Content="停止 DAQ" Command="{Binding StopDaqCommand}"
Style="{StaticResource WarnBtn}" />
</StackPanel>
<Separator Margin="0,4" />
<StackPanel Orientation="Horizontal" Margin="0,4">
<Button Content="接收一帧 DTO" Command="{Binding ReceiveDtoCommand}"
Style="{StaticResource CmdBtn}"
ToolTip="从接收队列取一帧 DAQ 数据(500ms 超时);注意 DAQ 运行期间命令响应与 DTO 共用队列" />
<TextBlock Text="{Binding DtoFrameCount, StringFormat='已收帧数: {0}'}"
VerticalAlignment="Center" Margin="12,0,8,0"
FontSize="12" Foreground="#616161" />
<TextBox IsReadOnly="True" Width="380" Height="30" FontSize="12"
FontFamily="Consolas" Background="#FAFAFA"
VerticalContentAlignment="Center"
ToolTip="最近收到的 DTO 帧原始数据(hex)"
Text="{Binding LatestDtoText, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</GroupBox>
<!-- ============ 日志 ============ -->
<GroupBox Header="日志" materialDesign:ColorZoneAssist.Mode="PrimaryLight">
<StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,4,2">
<Button Content="清空日志" Command="{Binding ClearLogCommand}"
Style="{StaticResource CmdBtn}" Height="26" />
</StackPanel>
<ScrollViewer Height="170" VerticalScrollBarVisibility="Auto">
<TextBox Text="{Binding ResponseLog, Mode=OneWay}"
IsReadOnly="True" TextWrapping="Wrap"
FontSize="11" FontFamily="Consolas"
Background="#FAFAFA" BorderThickness="0"
VerticalAlignment="Top" />
</ScrollViewer>
</StackPanel>
</GroupBox>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace TSMasterXCPModule.Views
{
/// <summary>
/// TSMasterXCPView.xaml 的交互逻辑
/// </summary>
public partial class TSMasterXCPView : UserControl
{
public TSMasterXCPView()
{
InitializeComponent();
}
}
}
+77 -49
View File
@@ -7,6 +7,7 @@ using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using TSMasterCAN; using TSMasterCAN;
using TSMaster; using TSMaster;
using Logger;
namespace UIShare.GlobalVariable namespace UIShare.GlobalVariable
{ {
public class CANMonitoringService public class CANMonitoringService
@@ -23,79 +24,106 @@ namespace UIShare.GlobalVariable
} }
public int Init(CancellationToken ct = default) public int Init(CancellationToken ct = default)
{ {
int re = 0; // 多工位共用同一个 TSMaster 实例:首次工位已完成 initialize_lib_tsmaster,
if (IsInitialized == false) // 后续工位的 CAN.Init 会直接复用首次结果(返回 0),因此这里不能再用
// “初始化未成功就跳过”的判断,否则第二个工位永远注册不到报文回调。
var initRe = CAN.Init(_systemConfig.TSMasterName);
if (initRe != 0)
{ {
re = CAN.Init(_systemConfig.TSMasterName); LoggerHelper.Warn($"CAN 驱动初始化返回 {initRe}({CAN.GetErrorDescription(initRe)}),继续注册报文回调。");
}
if (IsInitialized == false)
{
re = CAN.RegisterListener(MonitorEvent);
if (re == 0) IsInitialized = true;
} }
// RegisterListener 幂等:重复调用只补充订阅者;驱动被 Release 后再 Init 也能重新挂回原生回调
var re = CAN.RegisterListener(MonitorEvent);
if (re == 0) IsInitialized = true;
return re; return re;
} }
public ConcurrentDictionary<string, TLIBCANFD> RealTimeMessages { get; set; } = new(); public ConcurrentDictionary<string, TLIBCANFD> RealTimeMessages { get; set; } = new();
public ConcurrentDictionary<string, double> RealTimeSignals { get; set; } = new(); public ConcurrentDictionary<string, double> RealTimeSignals { get; set; } = new();
/// <summary>
/// TSMaster 原生库收报文回调(原生线程直接调用)。
/// 回调内抛出的异常一旦跨出原生边界会直接导致进程闪退,
/// 因此整个方法体必须包裹异常隔离,且不做任何可能阻塞的调用。
/// </summary>
public void MonitorEvent(ref int AObj, ref TLIBCANFD AData) public void MonitorEvent(ref int AObj, ref TLIBCANFD AData)
{ {
if (IsStopped) return; try
byte[] oldbytes = null;
//记录至实时报文数据库
var cpAData = AData;
cpAData.FData = ArrayPool<byte>.Shared.Rent(64);
Array.Copy(AData.FData, 0, cpAData.FData, 0, cpAData.FData.Length);
var MessageName = $"{AData.FIdxChn}/{AData.FIdentifier}";
if (RealTimeMessages.TryGetValue(MessageName, out var v))
{ {
oldbytes = v.FData; if (IsStopped) return;
}
RealTimeMessages[MessageName] = cpAData;
//记录至DBC数据库 // 通道越界防护:多通道/异常帧的 FIdxChn 可能超出 DBC 数据库(12 通道)范围
var dbcfind = DBCParse.MsgDatabase[AData.FIdxChn].FirstOrDefault(s => s.msg_id == cpAData.FIdentifier); if (AData.FIdxChn >= DBCParse.MsgDatabase.Count) return;
if (dbcfind != null) if (AData.FData == null) return;
{
Array.Copy(cpAData.FData, 0, dbcfind.ACANFD.FData, 0, cpAData.FData.Length);
dbcfind.ACANFD.FTimeUS = cpAData.FTimeUS;
dbcfind.ACANFD.FIsFD = cpAData.FIsFD;
dbcfind.ACANFD.FIsBRS = cpAData.FIsBRS;
dbcfind.ACANFD.FIsESI = cpAData.FIsESI;
dbcfind.ACANFD.FIsError = cpAData.FIsError;
dbcfind.ACANFD.FIsRemote = cpAData.FIsRemote;
dbcfind.ACANFD.FIsExt = cpAData.FIsExt;
}
if (oldbytes is not null) ArrayPool<byte>.Shared.Return(oldbytes); byte[] oldbytes = null;
var id = AData.FIdentifier; //记录至实时报文数据库
var ch = AData.FIdxChn; var cpAData = AData;
cpAData.FData = ArrayPool<byte>.Shared.Rent(64);
var canSignalList = _systemConfig.ConfigurationList.Where(s => s.MessageID == id && s.Channel == ch).ToArray(); Array.Copy(AData.FData, 0, cpAData.FData, 0, Math.Min(AData.FData.Length, cpAData.FData.Length));
var MessageName = $"{AData.FIdxChn}/{AData.FIdentifier}";
if (canSignalList.Length > 0) if (RealTimeMessages.TryGetValue(MessageName, out var v))
{
var find = DBCParse.MsgDatabase[ch].First(s => s.msg_id == id);
foreach (var item in canSignalList)
{ {
double Signalre = double.NaN; oldbytes = v.FData;
var re = CAN.GetSignalValue(ref cpAData, item.MessageName, item.SignalName, ref Signalre);
//Debug.Assert(re == 0);
var SinalName = $"{AData.FIdxChn}/{item.MessageName}/{item.SignalName}";
RealTimeSignals[SinalName] = Signalre;
} }
RealTimeMessages[MessageName] = cpAData;
//记录至DBC数据库
var dbcfind = DBCParse.MsgDatabase[AData.FIdxChn].FirstOrDefault(s => s.msg_id == cpAData.FIdentifier);
if (dbcfind != null)
{
Array.Copy(cpAData.FData, 0, dbcfind.ACANFD.FData, 0, Math.Min(cpAData.FData.Length, dbcfind.ACANFD.FData.Length));
dbcfind.ACANFD.FTimeUS = cpAData.FTimeUS;
dbcfind.ACANFD.FIsFD = cpAData.FIsFD;
dbcfind.ACANFD.FIsBRS = cpAData.FIsBRS;
dbcfind.ACANFD.FIsESI = cpAData.FIsESI;
dbcfind.ACANFD.FIsError = cpAData.FIsError;
dbcfind.ACANFD.FIsRemote = cpAData.FIsRemote;
dbcfind.ACANFD.FIsExt = cpAData.FIsExt;
}
if (oldbytes is not null) ArrayPool<byte>.Shared.Return(oldbytes);
var id = AData.FIdentifier;
var ch = AData.FIdxChn;
var configList = _systemConfig.ConfigurationList;
if (configList == null) return;
var canSignalList = configList.Where(s => s.MessageID == id && s.Channel == ch).ToArray();
if (canSignalList.Length > 0)
{
// DBC 中查不到该报文时静默跳过(配置与 DBC 不一致不应导致进程崩溃)
var find = DBCParse.MsgDatabase[ch].FirstOrDefault(s => s.msg_id == id);
if (find == null) return;
foreach (var item in canSignalList)
{
double Signalre = double.NaN;
var re = CAN.GetSignalValue(ref cpAData, item.MessageName, item.SignalName, ref Signalre);
//Debug.Assert(re == 0);
var SinalName = $"{AData.FIdxChn}/{item.MessageName}/{item.SignalName}";
RealTimeSignals[SinalName] = Signalre;
}
}
}
catch (Exception ex)
{
// 原生回调内绝不能向外抛异常:跨原生边界即进程闪退。
LoggerHelper.Error($"CANMonitoringService.MonitorEvent 回调异常(已隔离):{ex.Message}");
} }
} }
/// <summary> /// <summary>
/// 停止监测:从广播器注销并清理实时数据。 /// 停止监测:注销原生报文回调、从广播器注销并清理实时数据。
/// <para>必须先反注册回调再释放实例,否则原生线程仍会回调到已停止的工位。</para>
/// </summary> /// </summary>
public void Stop() public void Stop()
{ {
IsStopped = true; IsStopped = true;
if (IsRegistered) if (IsRegistered)
{ {
CAN.UnRegisterListener(MonitorEvent);
CANSignalBroadcaster.UnregisterService(this); CANSignalBroadcaster.UnregisterService(this);
IsRegistered = false; IsRegistered = false;
} }
+19 -11
View File
@@ -150,20 +150,28 @@ namespace UIShare.GlobalVariable
string methodName = BuildMethodName((uint)cfg.MessageID, signalName); string methodName = BuildMethodName((uint)cfg.MessageID, signalName);
// 广播信号值到对应作用域 // 广播信号值到对应作用域
_eventAggregator.GetEvent<HardwareDataReportedEvent>().Publish(new HardwareReportArgs // 逐信号隔离:单个信号广播/报警失败不应中断整个广播循环
try
{ {
Scope = scopeName, _eventAggregator.GetEvent<HardwareDataReportedEvent>().Publish(new HardwareReportArgs
HardwareFingerprint = fingerprint, {
MethodName = methodName, Scope = scopeName,
Value = kvp.Value, HardwareFingerprint = fingerprint,
Time = DateTime.Now MethodName = methodName,
}); Value = kvp.Value,
Time = DateTime.Now
});
// 报警检查(使用该作用域自己的配置) // 报警检查(使用该作用域自己的配置)
string alarmStatus = ValueLimitAlarmHelper.CheckAlarm(fingerprint, methodName, kvp.Value, scopeConfig); string alarmStatus = ValueLimitAlarmHelper.CheckAlarm(fingerprint, methodName, kvp.Value, scopeConfig);
if (!string.IsNullOrEmpty(alarmStatus) && alarmStatus != "未报警") if (!string.IsNullOrEmpty(alarmStatus) && alarmStatus != "未报警")
{
_eventAggregator.GetEvent<AlarmEvent>().Publish((scopeName, canFingerprint, alarmStatus));
}
}
catch (Exception ex)
{ {
_eventAggregator.GetEvent<AlarmEvent>().Publish((scopeName, canFingerprint, alarmStatus)); Logger.LoggerHelper.Error($"CANSignalBroadcaster 广播信号异常(作用域 {scopeName} {methodName},已隔离):{ex.Message}");
} }
} }
} }
+81 -7
View File
@@ -48,10 +48,39 @@ namespace UIShare.GlobalVariable
_CANMonitoringService=new CANMonitoringService(_systemConfig); _CANMonitoringService=new CANMonitoringService(_systemConfig);
// 用 SystemConfig.Title 作为作用域唯一标识,无需反查 ConfigDic // 用 SystemConfig.Title 作为作用域唯一标识,无需反查 ConfigDic
_scopeName = _systemConfig.Title; _scopeName = _systemConfig.Title;
AcquireCanScope();
InitDevices(); InitDevices();
InitCAN(); InitCAN();
} }
/// <summary>本工位(作用域)是否已登记 CAN 卡引用,保证 Acquire/Release 成对且幂等</summary>
private bool _canScopeAcquired;
/// <summary>
/// 登记本工位对 CAN 卡的引用。
/// <para>CAN 卡是一拖多共享硬件,与 HardwarePool 中的设备一样需要按作用域引用计数。</para>
/// </summary>
private void AcquireCanScope()
{
if (_canScopeAcquired) return;
CAN.AcquireScope(_scopeName);
_canScopeAcquired = true;
}
/// <summary>
/// 注销本工位对 CAN 卡的引用。
/// <para>
/// 只有最后一个工位释放时才真正停止循环报文、断开总线并释放 TSMaster 驱动;
/// 仍有其他工位在用时只做引用减一,避免误伤其他工位。
/// </para>
/// </summary>
private void ReleaseCanScope()
{
if (!_canScopeAcquired) return;
_canScopeAcquired = false;
CAN.ReleaseScope(_scopeName);
}
public void InitCAN() public void InitCAN()
{ {
var re1 = _CANMonitoringService.Init(); var re1 = _CANMonitoringService.Init();
@@ -112,6 +141,13 @@ namespace UIShare.GlobalVariable
{ {
if (config == null || !config.IsEnabled) continue; if (config == null || !config.IsEnabled) continue;
// CAN 卡由 ConnectCanAsync 独立管理(静态 TSMaster 驱动 + 作用域引用计数),
// 不参与 HardwarePool 的设备实例池,这里直接跳过避免误报"未知设备类型"
if (string.Equals(config.ConnectionType, "CAN", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.IsNullOrWhiteSpace(config.DeviceType) || if (string.IsNullOrWhiteSpace(config.DeviceType) ||
!_deviceTypeMap.TryGetValue(config.DeviceType, out var deviceType)) !_deviceTypeMap.TryGetValue(config.DeviceType, out var deviceType))
{ {
@@ -314,11 +350,18 @@ namespace UIShare.GlobalVariable
// 先停止健康监控,避免重连定时器与关闭操作冲突 // 先停止健康监控,避免重连定时器与关闭操作冲突
StopHealthMonitor(); StopHealthMonitor();
// CAN 卡按作用域引用计数释放:仅最后一个工位关闭时才停循环报文、断总线并释放驱动
ReleaseCanScope();
List<Task> tasks = new List<Task>(); List<Task> tasks = new List<Task>();
lock (_lockObj) lock (_lockObj)
{ {
if (DeviceMap.Count == 0 ) return; if (DeviceMap.Count == 0)
{
LoggerHelper.Info("所有设备已执行关闭操作。");
return;
}
foreach (var kvp in DeviceMap) foreach (var kvp in DeviceMap)
{ {
@@ -331,7 +374,6 @@ namespace UIShare.GlobalVariable
} }
} }
CAN.DisConnect();
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
LoggerHelper.Info("所有设备已执行关闭操作。"); LoggerHelper.Info("所有设备已执行关闭操作。");
} }
@@ -453,9 +495,16 @@ namespace UIShare.GlobalVariable
try try
{ {
// 重新连接时恢复本工位对 CAN 卡的引用(之前可能被主动关闭过)
AcquireCanScope();
// 驱动若曾被最后一个工位释放,这里重新初始化并把报文回调挂回原生库
InitCAN();
bool ok = await Task.Run(() => bool ok = await Task.Run(() =>
{ {
// 自动加载 DBC 文件 // 自动加载 DBC 文件
// 一拖多共享 CAN 卡:同一通道 + 同一文件只加载一次,
// 其他工位已经加载过时直接跳过,避免重复下发并重复追加 DBCParse 报文库
if (_systemConfig?.DBCAutoLoadList != null) if (_systemConfig?.DBCAutoLoadList != null)
{ {
foreach (var item in _systemConfig.DBCAutoLoadList) foreach (var item in _systemConfig.DBCAutoLoadList)
@@ -474,11 +523,22 @@ namespace UIShare.GlobalVariable
LoggerHelper.Warn($"CAN 通道 {item.DBCChannel} 自动加载 DBC 失败:文件不存在 [{item.DBCFilePath}]"); LoggerHelper.Warn($"CAN 通道 {item.DBCChannel} 自动加载 DBC 失败:文件不存在 [{item.DBCFilePath}]");
continue; continue;
} }
bool loadOk = CAN.LoadDBC(item.DBCFilePath,new int[] { item.DBCChannel },out _ )==0;
var dbcChannels = new int[] { item.DBCChannel };
bool alreadyLoaded = CAN.IsDbcLoaded(item.DBCFilePath, dbcChannels);
bool loadOk = CAN.LoadDBCOnce(item.DBCFilePath, dbcChannels, out _) == 0;
if (loadOk) if (loadOk)
{ {
LoggerHelper.Info($"CAN 通道 {item.DBCChannel} 已自动加载 DBC:{item.DBCFilePath}"); if (alreadyLoaded)
// 发布 DBC 加载完成事件,通知监控系统刷新信号列表 {
LoggerHelper.Info($"CAN 通道 {item.DBCChannel} DBC 已由其他工位加载,跳过重复加载:{item.DBCFilePath}");
}
else
{
LoggerHelper.Info($"CAN 通道 {item.DBCChannel} 已自动加载 DBC:{item.DBCFilePath}");
}
// 即使跳过加载,也要通知本工位监控系统刷新信号列表
_eventAggregator.GetEvent<DBCLoadedEvent>().Publish(new DBCLoadedArgs _eventAggregator.GetEvent<DBCLoadedEvent>().Publish(new DBCLoadedArgs
{ {
Channel = (uint)item.DBCChannel, Channel = (uint)item.DBCChannel,
@@ -519,11 +579,22 @@ namespace UIShare.GlobalVariable
} }
/// <summary> /// <summary>
/// 关闭 CAN 卡 /// 关闭 CAN 卡:按作用域引用计数释放。
/// <para>
/// CAN 卡为一拖多共享硬件,这里只注销本工位的引用;
/// 仅当最后一个工位释放时,<see cref="CAN.ReleaseScope"/> 才停止循环报文、断开总线并释放驱动。
/// </para>
/// </summary> /// </summary>
private void CloseCan() private void CloseCan()
{ {
CAN.DisConnect(); try
{
ReleaseCanScope();
}
catch (Exception ex)
{
LoggerHelper.Error($"关闭 CAN 卡异常(已隔离):{ex.Message}");
}
} }
private static IReadOnlyDictionary<string, Type> BuildDeviceTypeMap() private static IReadOnlyDictionary<string, Type> BuildDeviceTypeMap()
@@ -612,6 +683,9 @@ namespace UIShare.GlobalVariable
// 停止 CAN 信号监测服务 // 停止 CAN 信号监测服务
_CANMonitoringService?.Stop(); _CANMonitoringService?.Stop();
// CAN 卡按作用域引用计数释放:最后一个工位销毁时才停循环报文、断总线并释放驱动
ReleaseCanScope();
if (string.IsNullOrEmpty(_scopeName)) return; if (string.IsNullOrEmpty(_scopeName)) return;
// 遍历当前作用域用到的所有指纹,逐一移除本作用域的引用 // 遍历当前作用域用到的所有指纹,逐一移除本作用域的引用
+379
View File
@@ -0,0 +1,379 @@
using System.Globalization;
using System.Text;
namespace XCPCore
{
/// <summary>
/// A2L 中的测量量(MEASUREMENT)条目
/// </summary>
public sealed class A2lMeasurement
{
/// <summary>测量量名称</summary>
public string Name { get; init; } = string.Empty;
/// <summary>测量量描述(A2L 中的 LongIdentifier)</summary>
public string Description { get; init; } = string.Empty;
/// <summary>A2L 数据类型(UBYTE / UWORD / FLOAT32_IEEE 等)</summary>
public string DataType { get; init; } = string.Empty;
/// <summary>ECU 内存地址</summary>
public uint Address { get; init; }
/// <summary>占用字节数(由数据类型推导)</summary>
public byte SizeBytes { get; init; }
/// <summary>地址的 hex 文本</summary>
public string AddressText => $"0x{Address:X8}";
/// <summary>长度的显示文本</summary>
public string SizeText => SizeBytes > 0 ? $"{SizeBytes}B" : "?";
/// <summary>列表 ToolTip 使用的完整描述</summary>
public string Detail => $"{Name} {AddressText} {DataType} {SizeText}\n{Description}".TrimEnd();
}
/// <summary>
/// A2L 中的标定量(CHARACTERISTIC)条目
/// </summary>
public sealed class A2lCharacteristic
{
/// <summary>标定量名称</summary>
public string Name { get; init; } = string.Empty;
/// <summary>标定量描述(A2L 中的 LongIdentifier)</summary>
public string Description { get; init; } = string.Empty;
/// <summary>标定量结构类型(VALUE / CURVE / MAP / VAL_BLK / ASCII 等)</summary>
public string CharacteristicType { get; init; } = string.Empty;
/// <summary>A2L 数据类型(由 RECORD_LAYOUT 的 FNC_VALUES 推导,未知时为空)</summary>
public string DataType { get; init; } = string.Empty;
/// <summary>ECU 内存地址</summary>
public uint Address { get; init; }
/// <summary>单元素字节数(未知时为 0)</summary>
public byte SizeBytes { get; init; }
/// <summary>地址的 hex 文本</summary>
public string AddressText => $"0x{Address:X8}";
/// <summary>长度的显示文本</summary>
public string SizeText => SizeBytes > 0 ? $"{SizeBytes}B" : "?";
/// <summary>列表 ToolTip 使用的完整描述</summary>
public string Detail => $"{Name} {AddressText} {CharacteristicType} {DataType} {SizeText}\n{Description}".TrimEnd();
}
/// <summary>
/// A2L(ASAM MCD-2 MC)描述文件解析结果
/// </summary>
public sealed class A2lDatabase
{
/// <summary>来源文件全路径</summary>
public string FilePath { get; set; } = string.Empty;
/// <summary>PROJECT 名称(A2L 中可能缺省)</summary>
public string ProjectName { get; set; } = string.Empty;
/// <summary>解析出的测量量列表</summary>
public List<A2lMeasurement> Measurements { get; } = new List<A2lMeasurement>();
/// <summary>解析出的标定量列表</summary>
public List<A2lCharacteristic> Characteristics { get; } = new List<A2lCharacteristic>();
/// <summary>解析结果摘要</summary>
public string Summary => $"MEASUREMENT {Measurements.Count} 项 · CHARACTERISTIC {Characteristics.Count} 项";
}
/// <summary>
/// 轻量 A2L 解析器:只提取 XCP 采集/标定所需的测量量、标定量及其地址与长度。
/// <para>
/// 支持块:MEASUREMENT、CHARACTERISTIC、RECORD_LAYOUT、PROJECT;其余块整体跳过。
/// 采用词法切分而非正则,能正确处理块内嵌套的 IF_DATA / AXIS_DESCR 等子块与 /* */ 注释。
/// </para>
/// </summary>
public static class A2lParser
{
private const char StringMark = '\u0001';
/// <summary>
/// 读取并解析 A2L 文件
/// </summary>
/// <param name="filePath">A2L 文件路径</param>
/// <returns>解析结果(无法识别的块被忽略,不抛异常)</returns>
public static A2lDatabase LoadFile(string filePath)
{
var db = Parse(ReadAllTextSmart(filePath));
db.FilePath = filePath;
return db;
}
/// <summary>
/// 解析 A2L 文本内容
/// </summary>
/// <param name="text">A2L 文件内容</param>
/// <returns>解析结果(无法识别的块被忽略,不抛异常)</returns>
public static A2lDatabase Parse(string text)
{
var db = new A2lDatabase();
if (string.IsNullOrWhiteSpace(text)) return db;
var recordLayouts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var tokens = Tokenize(text);
for (int i = 0; i + 1 < tokens.Count; i++)
{
if (!IsKeyword(tokens[i], "/begin")) continue;
string block = tokens[i + 1].ToUpperInvariant();
int contentStart = i + 2;
int end = FindBlockEnd(tokens, contentStart, block);
if (end <= contentStart) { i = end; continue; }
switch (block)
{
case "PROJECT":
case "MODULE":
// 容器块:MEASUREMENT/CHARACTERISTIC 都嵌套在 MODULE 内,必须继续扫描而不能整块跳过
// /begin PROJECT <Name> <LongIdentifier>
if (block == "PROJECT" && contentStart < end)
{
db.ProjectName = tokens[contentStart];
}
continue;
case "RECORD_LAYOUT":
ParseRecordLayout(tokens, contentStart, end, recordLayouts);
break;
case "MEASUREMENT":
ParseMeasurement(tokens, contentStart, end, db);
break;
case "CHARACTERISTIC":
ParseCharacteristic(tokens, contentStart, end, recordLayouts, db);
break;
}
i = end;
}
return db;
}
/// <summary>解析 MEASUREMENT 块:/begin MEASUREMENT Name Desc DataType Conv Resolution Accuracy Lower Upper ... ECU_ADDRESS x</summary>
private static void ParseMeasurement(List<string> t, int start, int end, A2lDatabase db)
{
// Name + LongIdentifier + DataType + Conversion + Resolution + Accuracy + Lower + Upper = 8 个必备字段
if (end - start < 8) return;
string name = t[start];
if (name.Length == 0 || name[0] == '/') return;
uint address = 0;
for (int i = start + 8; i + 1 < end; i++)
{
if (IsKeyword(t[i], "ECU_ADDRESS"))
{
address = ParseHexUInt32(t[i + 1]);
break;
}
}
if (address == 0) return; // 没有地址的测量量无法用于 DAQ
string dataType = t[start + 2].ToUpperInvariant();
db.Measurements.Add(new A2lMeasurement
{
Name = name,
Description = Unquote(t[start + 1]),
DataType = dataType,
Address = address,
SizeBytes = GetDataTypeSize(dataType)
});
}
/// <summary>解析 CHARACTERISTIC 块:/begin CHARACTERISTIC Name Desc Type Address Deposit MaxDiff Conversion Lower Upper ...</summary>
private static void ParseCharacteristic(List<string> t, int start, int end,
Dictionary<string, string> recordLayouts, A2lDatabase db)
{
// Name + LongIdentifier + Type + Address + Deposit + MaxDiff + Conversion + Lower + Upper = 9 个必备字段
if (end - start < 9) return;
string name = t[start];
if (name.Length == 0 || name[0] == '/') return;
uint address = ParseHexUInt32(t[start + 3]);
if (address == 0) return;
string type = t[start + 2].ToUpperInvariant();
string deposit = t[start + 4];
string dataType = recordLayouts.TryGetValue(deposit, out var layoutType) ? layoutType : string.Empty;
db.Characteristics.Add(new A2lCharacteristic
{
Name = name,
Description = Unquote(t[start + 1]),
CharacteristicType = type,
DataType = dataType,
Address = address,
SizeBytes = GetCharacteristicSize(type, dataType)
});
}
/// <summary>解析 RECORD_LAYOUT 块,记录 FNC_VALUES 的元素数据类型,供 CHARACTERISTIC 推导长度</summary>
private static void ParseRecordLayout(List<string> t, int start, int end,
Dictionary<string, string> recordLayouts)
{
if (start >= end) return;
string layoutName = t[start];
for (int i = start + 1; i + 3 < end; i++)
{
if (IsKeyword(t[i], "FNC_VALUES"))
{
// FNC_VALUES <position> <datatype> <indexMode> <addressType>
recordLayouts[layoutName] = t[i + 2].ToUpperInvariant();
return;
}
}
}
/// <summary>标定量长度:VALUE 取元素大小;CURVE/MAP/VAL_BLK 的维度信息在 AXIS_DESCR 中,这里按单元素估算</summary>
private static byte GetCharacteristicSize(string characteristicType, string dataType)
{
if (characteristicType == "ASCII") return 1;
return GetDataTypeSize(dataType);
}
/// <summary>A2L 数据类型 → 字节数(未知返回 0)</summary>
private static byte GetDataTypeSize(string dataType)
{
switch (dataType)
{
case "UBYTE":
case "SBYTE":
case "BYTE":
return 1;
case "UWORD":
case "SWORD":
case "WORD":
return 2;
case "ULONG":
case "SLONG":
case "LONG":
return 4;
case "A_UINT64":
case "A_INT64":
return 8;
case "FLOAT16_IEEE":
case "FLOAT16":
return 2;
case "FLOAT32_IEEE":
case "FLOAT32":
return 4;
case "FLOAT64_IEEE":
case "FLOAT64":
return 8;
default:
return 0;
}
}
/// <summary>找到与 /begin 匹配的 /end 位置(支持同名块嵌套)</summary>
private static int FindBlockEnd(List<string> tokens, int start, string block)
{
int depth = 1;
for (int i = start; i + 1 < tokens.Count; i++)
{
if (!IsKeyword(tokens[i], "/begin") && !IsKeyword(tokens[i], "/end")) continue;
if (!string.Equals(tokens[i + 1], block, StringComparison.OrdinalIgnoreCase)) continue;
if (IsKeyword(tokens[i], "/begin"))
{
depth++;
}
else if (--depth == 0)
{
return i;
}
}
return tokens.Count;
}
/// <summary>词法切分:跳过 /* */ 与 // 注释,字符串字面量整体保留(首字符加标记区分)</summary>
private static List<string> Tokenize(string text)
{
var tokens = new List<string>(text.Length / 8);
int i = 0, n = text.Length;
while (i < n)
{
char c = text[i];
if (char.IsWhiteSpace(c)) { i++; continue; }
if (c == '/' && i + 1 < n && text[i + 1] == '*')
{
int commentEnd = text.IndexOf("*/", i + 2, StringComparison.Ordinal);
i = commentEnd < 0 ? n : commentEnd + 2;
continue;
}
if (c == '/' && i + 1 < n && text[i + 1] == '/')
{
int lineEnd = text.IndexOf('\n', i + 2);
i = lineEnd < 0 ? n : lineEnd + 1;
continue;
}
if (c == '"')
{
var sb = new StringBuilder();
i++;
while (i < n)
{
if (text[i] == '\\' && i + 1 < n) { sb.Append(text[i + 1]); i += 2; continue; }
if (text[i] == '"') { i++; break; }
sb.Append(text[i]);
i++;
}
tokens.Add(StringMark + sb.ToString());
continue;
}
int tokenStart = i;
while (i < n && !char.IsWhiteSpace(text[i]) && text[i] != '"'
&& !(text[i] == '/' && i + 1 < n && (text[i + 1] == '*' || text[i + 1] == '/')))
{
i++;
}
if (i > tokenStart) tokens.Add(text.Substring(tokenStart, i - tokenStart));
}
return tokens;
}
private static bool IsKeyword(string token, string keyword)
=> string.Equals(token, keyword, StringComparison.OrdinalIgnoreCase);
private static bool IsString(string token) => token.Length > 0 && token[0] == StringMark;
private static string Unquote(string token)
=> IsString(token) ? token[1..].Trim() : string.Empty;
/// <summary>A2L 中的地址一律按 hex 解析(允许 0x 前缀)</summary>
private static uint ParseHexUInt32(string token)
{
if (string.IsNullOrEmpty(token)) return 0;
var text = token.Trim();
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) text = text[2..];
return uint.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value) ? value : 0;
}
/// <summary>按 BOM / UTF-8 严格校验读取文本,非 UTF-8(GBK 等)退化为 Latin1,保证 ASCII 关键字可解析</summary>
private static string ReadAllTextSmart(string filePath)
{
var bytes = File.ReadAllBytes(filePath);
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
return Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3);
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
return Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2);
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
return Encoding.BigEndianUnicode.GetString(bytes, 2, bytes.Length - 2);
try
{
return new UTF8Encoding(false, true).GetString(bytes);
}
catch (DecoderFallbackException)
{
return Encoding.Latin1.GetString(bytes);
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.Collections.Generic;
namespace XCPCore
{
/// <summary>
/// DAQ 列表配置,描述一个完整的 DAQ 采集方案
/// </summary>
public class DaqListConfig
{
/// <summary>
/// DAQ 列表编号
/// </summary>
public ushort DaqListNumber { get; set; }
/// <summary>
/// DAQ 列表模式:0x00=默认,0x01=时间戳附加,0x02=优先级,0x04=预设 DAQ 列表
/// </summary>
public byte Mode { get; set; } = 0x00;
/// <summary>
/// 事件通道编号(决定采集触发源)
/// </summary>
public ushort EventChannel { get; set; }
/// <summary>
/// 预分频值(每 N 个事件触发一次采集,0 表示每个事件都采集)
/// </summary>
public ushort Prescaler { get; set; } = 1;
/// <summary>
/// DAQ 列表是否启动(true=启动,false=停止)
/// </summary>
public bool Enable { get; set; } = true;
/// <summary>
/// ODT(Object Descriptor Table)条目列表
/// 每个 ODT 描述一组要采集的信号(地址 + 长度)
/// </summary>
public List<OdEntry> OdEntries { get; set; } = new List<OdEntry>();
}
/// <summary>
/// OD(Object Descriptor)条目,描述单个要采集的信号
/// </summary>
public class OdEntry
{
/// <summary>
/// 信号地址(从站内存地址)
/// </summary>
public uint Address { get; set; }
/// <summary>
/// 地址扩展(用于 > 32 位地址的从站)
/// </summary>
public byte AddressExtension { get; set; } = 0;
/// <summary>
/// 信号长度(字节)
/// </summary>
public byte Length { get; set; }
/// <summary>
/// 信号名称(便于上层引用,非协议字段)
/// </summary>
public string Name { get; set; } = "";
}
/// <summary>
/// XCP 协议配置模型(由 ATS 传入 XcpMaster 使用)
/// </summary>
public class XcpConfig
{
/// <summary>连接超时时间(毫秒)</summary>
public int ConnectTimeoutMs { get; set; } = 3000;
/// <summary>命令响应超时时间(毫秒)</summary>
public int CommandTimeoutMs { get; set; } = 1000;
/// <summary>
/// 地址粒度:1=BYTE,2=WORD,4=DWORD
/// 影响 SET_MTA 和 UPLOAD 等命令中地址和长度的单位
/// </summary>
public byte AddressGranularity { get; set; } = 1;
}
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace XCPCore
{
/// <summary>
/// XCP 传输层抽象接口,隔离协议核心与底层通信。
/// 实现此接口的类负责 CTO(命令/响应)和 DTO(DAQ 数据)帧的收发。
/// </summary>
public interface IXcpTransport
{
/// <summary>
/// 发送 CTO 帧(命令请求)
/// </summary>
/// <param name="data">CTO 帧数据(含 PID + 参数)</param>
/// <param name="ct">取消令牌</param>
Task SendCtoAsync(byte[] data, CancellationToken ct = default);
/// <summary>
/// 接收 CTO 帧(命令响应,带超时控制)
/// </summary>
/// <param name="timeoutMs">超时时间(毫秒)</param>
/// <param name="ct">取消令牌</param>
/// <returns>响应帧数据(首字节为 PID)</returns>
/// <exception cref="TimeoutException">接收超时</exception>
Task<byte[]> ReceiveCtoAsync(int timeoutMs, CancellationToken ct = default);
/// <summary>
/// 发送 DTO 帧(DAQ 数据传输)
/// </summary>
/// <param name="data">DTO 帧数据</param>
/// <param name="ct">取消令牌</param>
Task SendDtoAsync(byte[] data, CancellationToken ct = default);
/// <summary>
/// 接收 DTO 帧(DAQ 数据接收,带超时控制)
/// </summary>
/// <param name="timeoutMs">超时时间(毫秒)</param>
/// <param name="ct">取消令牌</param>
/// <returns>DTO 帧数据</returns>
/// <exception cref="TimeoutException">接收超时</exception>
Task<byte[]> ReceiveDtoAsync(int timeoutMs, CancellationToken ct = default);
/// <summary>
/// 打开传输层连接(初始化底层通信资源)
/// </summary>
/// <param name="ct">取消令牌</param>
/// <returns>是否成功打开</returns>
Task<bool> OpenAsync(CancellationToken ct = default);
/// <summary>
/// 关闭传输层连接(释放底层通信资源)
/// </summary>
void Close();
/// <summary>
/// 传输层是否已打开
/// </summary>
bool IsOpen { get; }
}
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\TSMasterCAN\TSMasterCAN.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Interop.TSMasterAPI">
<HintPath>..\..\TSMasterCAN\Interop.TSMasterAPI.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
+263
View File
@@ -0,0 +1,263 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using TSMaster;
using TSMasterCAN;
namespace XCPCore
{
/// <summary>
/// XCP over CAN 传输层实现(同星 TSMaster 后端)。
/// 命令发送复用 <see cref="CAN.SendMsg"/>(TSMaster 通道报文 API),
/// 接收走 TSMaster 接收 FIFO(tsfifo_receive_canfd_msgs_list / tsfifo_receive_can_msgs_list),
/// 由后台轮询任务按 DTO ID 过滤后推入队列。
/// </summary>
public class XcpCanTransport : IXcpTransport
{
private readonly byte _channel;
private readonly uint _croId;
private readonly uint _dtoId;
private readonly bool _isExtended;
private readonly bool _isCanFd;
private readonly int _timeoutMs;
// DTO 接收队列(由 CAN 轮询任务填充)
private readonly ConcurrentQueue<byte[]> _responseQueue = new ConcurrentQueue<byte[]>();
private readonly SemaphoreSlim _responseSignal = new SemaphoreSlim(0);
private CancellationTokenSource? _receiveCts;
private Task? _receiveTask;
/// <summary>传输层是否已打开</summary>
public bool IsOpen { get; private set; }
/// <summary>CRO ID(发送命令的 CAN ID)</summary>
public uint CroId => _croId;
/// <summary>DTO ID(接收响应的 CAN ID)</summary>
public uint DtoId => _dtoId;
/// <summary>
/// 创建 CAN XCP 传输层实例(同星 TSMaster 后端)
/// </summary>
/// <param name="channel">CAN 通道号</param>
/// <param name="croId">CRO CAN ID(发送命令)</param>
/// <param name="dtoId">DTO CAN ID(接收响应)</param>
/// <param name="isExtended">是否使用扩展帧(29位 ID)</param>
/// <param name="isCanFd">是否使用 CAN FD 帧</param>
/// <param name="timeoutMs">默认超时时间(毫秒)</param>
public XcpCanTransport(byte channel,
uint croId, uint dtoId, bool isExtended = false, bool isCanFd = false, int timeoutMs = 3000)
{
_channel = channel;
_croId = croId;
_dtoId = dtoId;
_isExtended = isExtended;
_isCanFd = isCanFd;
_timeoutMs = timeoutMs;
}
/// <summary>
/// 打开传输层(启用 TSMaster 接收 FIFO 并启动 DTO 接收轮询任务)
/// </summary>
public Task<bool> OpenAsync(CancellationToken ct = default)
{
if (IsOpen) return Task.FromResult(true);
// 启用 TSMaster 接收 FIFO(全局开关,幂等)并清空本通道残留帧
TsMasterApi.tsfifo_enable_receive_fifo();
ClearFifoBuffers();
_receiveCts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoop(_receiveCts.Token), _receiveCts.Token);
IsOpen = true;
Debug.WriteLine($"XCP CAN 传输层已打开: CH={_channel}, CRO=0x{_croId:X}, DTO=0x{_dtoId:X}, FD={_isCanFd}");
return Task.FromResult(true);
}
/// <summary>
/// 发送 CTO 帧(构造 CAN 帧并经 CAN.SendMsg 发送)
/// </summary>
public Task SendCtoAsync(byte[] data, CancellationToken ct = default)
{
if (!IsOpen) throw new InvalidOperationException("XCP CAN 传输层未打开");
// 限制 CAN 帧最大长度
int maxLen = _isCanFd ? 64 : 8;
int sendLen = Math.Min(data.Length, maxLen);
byte[] sendData = new byte[maxLen];
Array.Copy(data, sendData, sendLen);
int result = CAN.SendMsg(
(APP_CHANNEL)_channel,
(int)_croId,
true, // AIsTx
_isExtended, // AIsExt
false, // AIsRemote
(byte)sendLen, // ADLC
sendData,
_isCanFd, // AIsFD
false, // AIsBRS
false, // isUpdateDBCDatabase
0 // sendPeriod (单次发送)
);
if (result != 0)
throw new InvalidOperationException($"XCP CAN 发送失败: TSCAN 返回值={result} ({CAN.GetErrorDescription(result)})");
return Task.CompletedTask;
}
/// <summary>
/// 接收 CTO 帧(等待 DTO 响应,带超时)
/// </summary>
public async Task<byte[]> ReceiveCtoAsync(int timeoutMs, CancellationToken ct = default)
{
if (!IsOpen) throw new InvalidOperationException("XCP CAN 传输层未打开");
var timeoutTask = Task.Delay(timeoutMs, ct);
var waitTask = _responseSignal.WaitAsync(ct);
var completed = await Task.WhenAny(waitTask, timeoutTask);
if (completed == timeoutTask)
throw new TimeoutException($"XCP CAN 接收超时 ({timeoutMs}ms), 等待 DTO ID=0x{_dtoId:X}");
if (_responseQueue.TryDequeue(out var response))
return response;
throw new TimeoutException("XCP CAN 接收队列为空");
}
/// <summary>
/// 发送 DTO 帧(CAN XCP 中 DTO 与 CTO 共用发送通道)
/// </summary>
public Task SendDtoAsync(byte[] data, CancellationToken ct = default) => SendCtoAsync(data, ct);
/// <summary>
/// 接收 DTO 帧(DAQ 数据接收)
/// </summary>
public Task<byte[]> ReceiveDtoAsync(int timeoutMs, CancellationToken ct = default) => ReceiveCtoAsync(timeoutMs, ct);
/// <summary>
/// 关闭传输层
/// </summary>
public void Close()
{
if (!IsOpen) return;
_receiveCts?.Cancel();
try { _receiveTask?.Wait(1000); } catch { }
_receiveTask = null;
_receiveCts?.Dispose();
_receiveCts = null;
// 清空队列
while (_responseQueue.TryDequeue(out _)) { }
ClearFifoBuffers();
IsOpen = false;
Debug.WriteLine("XCP CAN 传输层已关闭");
}
// ============================================================
// 内部方法
// ============================================================
/// <summary>
/// 清空 TSMaster 本通道接收 FIFO 残留帧
/// </summary>
private void ClearFifoBuffers()
{
if (_isCanFd)
TsMasterApi.tsfifo_clear_canfd_receive_buffers(_channel);
else
TsMasterApi.tsfifo_clear_can_receive_buffers(_channel);
}
/// <summary>
/// 后台接收轮询任务:从 TSMaster 接收 FIFO 读取报文,按 DTO ID 过滤后推入队列
/// </summary>
private async Task ReceiveLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
if (_isCanFd)
ReceiveFdFromFifo();
else
ReceiveClassicFromFifo();
// 轮询间隔:1ms 足以满足大多数 XCP 场景
await Task.Delay(1, ct).ContinueWith(_ => { }, TaskContinuationOptions.OnlyOnCanceled);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Debug.WriteLine($"XCP CAN 接收循环异常: {ex.Message}");
await Task.Delay(10, ct).ContinueWith(_ => { }, TaskContinuationOptions.OnlyOnCanceled);
}
}
}
/// <summary>
/// 从 TSMaster 接收 FIFO 读取 CAN FD 帧并按 DTO ID 过滤
/// </summary>
private void ReceiveFdFromFifo()
{
int count = 0;
if (TsMasterApi.tsfifo_read_canfd_rx_buffer_frame_count(_channel, ref count) != 0 || count <= 0)
return;
var buffers = new TLIBCANFD[count];
int size = count;
if (TsMasterApi.tsfifo_receive_canfd_msgs_list(ref buffers, ref size, _channel, false) != 0)
return;
for (int i = 0; i < size; i++)
{
EnqueueIfDto(buffers[i].FIdxChn, (uint)buffers[i].FIdentifier, buffers[i].FDLC, buffers[i].FData);
}
}
/// <summary>
/// 从 TSMaster 接收 FIFO 读取经典 CAN 帧并按 DTO ID 过滤
/// </summary>
private void ReceiveClassicFromFifo()
{
int count = 0;
if (TsMasterApi.tsfifo_read_can_rx_buffer_frame_count(_channel, ref count) != 0 || count <= 0)
return;
var buffers = new TLIBCAN[count];
int size = count;
if (TsMasterApi.tsfifo_receive_can_msgs_list(ref buffers, ref size, _channel, false) != 0)
return;
for (int i = 0; i < size; i++)
{
EnqueueIfDto(buffers[i].FIdxChn, (uint)buffers[i].FIdentifier, buffers[i].FDLC, buffers[i].FData);
}
}
/// <summary>
/// 校验帧(通道 + DTO ID 匹配)后截取有效数据入队并释放等待信号
/// </summary>
private void EnqueueIfDto(byte frameChannel, uint frameId, byte dlc, byte[] data)
{
if (frameChannel != _channel || frameId != _dtoId)
return;
int len = Math.Min((int)dlc, data?.Length ?? 0);
var payload = new byte[len];
if (len > 0)
Array.Copy(data, payload, len);
_responseQueue.Enqueue(payload);
_responseSignal.Release();
}
}
}
+491
View File
@@ -0,0 +1,491 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace XCPCore
{
/// <summary>
/// XCP 主站协议核心,处理 CTO/DTO 帧、状态机和 DAQ 列表管理。
/// 协议核心逻辑与传输层完全解耦,通过 <see cref="IXcpTransport"/> 接口注入。
/// </summary>
public class XcpMaster
{
private readonly IXcpTransport _transport;
private readonly XcpConfig _config;
private XcpSessionState _state = XcpSessionState.Disconnected;
private readonly object _cmdLock = new object();
/// <summary>CONNECT 成功后缓存的从站能力信息</summary>
public XcpSlaveInfo SlaveInfo { get; private set; } = new XcpSlaveInfo();
/// <summary>当前会话状态</summary>
public XcpSessionState State => _state;
/// <summary>传输层实例(供外部查询传输层类型)</summary>
public IXcpTransport Transport => _transport;
/// <summary>
/// 创建 XCP 主站实例
/// </summary>
/// <param name="transport">传输层实现(CAN 或 UDP)</param>
/// <param name="config">协议配置</param>
public XcpMaster(IXcpTransport transport, XcpConfig config)
{
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_config = config ?? throw new ArgumentNullException(nameof(config));
}
// ============================================================
// 标准命令
// ============================================================
/// <summary>
/// CONNECT 命令 - 建立与从站的逻辑连接
/// </summary>
/// <param name="mode">连接模式:0x00=正常,0x01=用户自定义</param>
/// <param name="ct">取消令牌</param>
/// <returns>连接是否成功</returns>
public async Task<bool> ConnectAsync(byte mode = 0x00, CancellationToken ct = default)
{
try
{
if (!_transport.IsOpen)
await _transport.OpenAsync(ct);
// CONNECT 命令: PID=0xFF, 参数=MODE(1byte)
var response = await SendCommandAsync(XcpCommand.CONNECT, new byte[] { mode }, ct);
if (response != null && response[0] == XcpResponseCode.POSITIVE)
{
SlaveInfo = ParseConnectResponse(response);
_state = XcpSessionState.Connected;
Debug.WriteLine($"XCP CONNECT 成功: MaxCto={SlaveInfo.MaxCtoSize}, MaxDto={SlaveInfo.MaxDtoSize}");
return true;
}
return false;
}
catch (Exception ex)
{
Debug.WriteLine($"XCP CONNECT 失败: {ex.Message}");
return false;
}
}
/// <summary>
/// DISCONNECT 命令 - 断开与从站的逻辑连接
/// </summary>
public async Task<bool> DisconnectAsync(CancellationToken ct = default)
{
try
{
if (_state == XcpSessionState.Disconnected)
return true;
var response = await SendCommandAsync(XcpCommand.DISCONNECT, Array.Empty<byte>(), ct);
_state = XcpSessionState.Disconnected;
return response != null && response[0] == XcpResponseCode.POSITIVE;
}
finally
{
_state = XcpSessionState.Disconnected;
}
}
/// <summary>
/// GET_STATUS 命令 - 获取从站当前状态
/// </summary>
/// <returns>状态字节,失败返回 null</returns>
public async Task<byte[]?> GetStatusAsync(CancellationToken ct = default)
{
var response = await SendCommandAsync(XcpCommand.GET_STATUS, Array.Empty<byte>(), ct);
if (response != null && response[0] == XcpResponseCode.POSITIVE)
return response;
return null;
}
// ============================================================
// 数据传输命令
// ============================================================
/// <summary>
/// SET_MTA 命令 - 设置内存传输地址
/// </summary>
/// <param name="address">目标地址</param>
/// <param name="addressExtension">地址扩展(通常 0)</param>
public async Task<bool> SetMtaAsync(uint address, byte addressExtension = 0, CancellationToken ct = default)
{
// SET_MTA 帧格式: [PID][0x00][0x00][ADDRESS_EXTENSION][ADDRESS(4bytes)]
var param = new byte[6];
param[0] = 0x00; // 保留
param[1] = 0x00; // 保留
param[2] = addressExtension;
param[3] = (byte)(address >> 24);
param[4] = (byte)(address >> 16);
param[5] = (byte)(address >> 8);
// 注意: 此处简化,完整实现需按 AddressGranularity 处理
var paramFull = new byte[8];
paramFull[0] = 0x00;
paramFull[1] = 0x00;
paramFull[2] = 0x00;
paramFull[3] = addressExtension;
paramFull[4] = (byte)((address >> 24) & 0xFF);
paramFull[5] = (byte)((address >> 16) & 0xFF);
paramFull[6] = (byte)((address >> 8) & 0xFF);
paramFull[7] = (byte)(address & 0xFF);
var response = await SendCommandAsync(XcpCommand.SET_MTA, paramFull, ct);
return response != null && response[0] == XcpResponseCode.POSITIVE;
}
/// <summary>
/// UPLOAD 命令 - 从从站读取数据(需先调用 SetMtaAsync 设置地址)
/// </summary>
/// <param name="length">读取长度(字节数,按 AddressGranularity 为单位)</param>
/// <returns>读取到的数据,失败返回 null</returns>
public async Task<byte[]?> UploadAsync(int length, CancellationToken ct = default)
{
// UPLOAD 帧格式: [PID][DATA_TRANSFER_LENGTH(1byte)]
var param = new byte[] { (byte)length };
var response = await SendCommandAsync(XcpCommand.UPLOAD, param, ct);
if (response != null && response[0] == XcpResponseCode.POSITIVE)
{
// 正响应后紧跟数据: [PID][DATA...]
var data = new byte[response.Length - 1];
Array.Copy(response, 1, data, 0, data.Length);
return data;
}
return null;
}
/// <summary>
/// SHORT_UPLOAD 命令 - 短上传(地址和数据在同一帧中)
/// </summary>
/// <param name="address">目标地址</param>
/// <param name="length">读取长度(字节数)</param>
/// <param name="addressExtension">地址扩展</param>
/// <returns>读取到的数据</returns>
public async Task<byte[]?> ShortUploadAsync(uint address, byte length, byte addressExtension = 0, CancellationToken ct = default)
{
// SHORT_UPLOAD 帧: [PID][LENGTH][0x00][ADDR_EXT][ADDR(4bytes)]
var param = new byte[7];
param[0] = length;
param[1] = 0x00;
param[2] = addressExtension;
param[3] = (byte)((address >> 24) & 0xFF);
param[4] = (byte)((address >> 16) & 0xFF);
param[5] = (byte)((address >> 8) & 0xFF);
param[6] = (byte)(address & 0xFF);
var response = await SendCommandAsync(XcpCommand.SHORT_UPLOAD, param, ct);
if (response != null && response[0] == XcpResponseCode.POSITIVE)
{
var data = new byte[response.Length - 1];
Array.Copy(response, 1, data, 0, data.Length);
return data;
}
return null;
}
/// <summary>
/// DOWNLOAD 命令 - 向从站写入数据(需先调用 SetMtaAsync 设置地址)
/// </summary>
/// <param name="data">要写入的数据</param>
/// <returns>是否成功</returns>
public async Task<bool> DownloadAsync(byte[] data, CancellationToken ct = default)
{
// DOWNLOAD 帧: [PID][DATA_TRANSFER_LENGTH(1byte)][DATA...]
var param = new byte[1 + data.Length];
param[0] = (byte)data.Length;
Array.Copy(data, 0, param, 1, data.Length);
var response = await SendCommandAsync(XcpCommand.DOWNLOAD, param, ct);
if (response != null && response[0] == XcpResponseCode.POSITIVE)
{
// 正响应: [PID][MAX_BS(1byte)]
return true;
}
return false;
}
/// <summary>
/// 便捷方法:读取指定地址的数据
/// </summary>
/// <param name="address">目标地址</param>
/// <param name="length">读取长度</param>
/// <param name="ct">取消令牌</param>
/// <returns>读取到的数据</returns>
public async Task<byte[]?> ReadMemoryAsync(uint address, int length, CancellationToken ct = default)
{
if (!await SetMtaAsync(address, 0, ct))
return null;
return await UploadAsync(length, ct);
}
/// <summary>
/// 便捷方法:向指定地址写入数据
/// </summary>
/// <param name="address">目标地址</param>
/// <param name="data">要写入的数据</param>
/// <param name="ct">取消令牌</param>
/// <returns>是否成功</returns>
public async Task<bool> WriteMemoryAsync(uint address, byte[] data, CancellationToken ct = default)
{
if (!await SetMtaAsync(address, 0, ct))
return false;
return await DownloadAsync(data, ct);
}
// ============================================================
// DAQ 命令
// ============================================================
/// <summary>
/// 配置 DAQ 列表(FREE_DAQ -> ALLOC_DAQ -> SET_DAQ_PTR -> WRITE_DAQ -> SET_DAQ_LIST_MODE)
/// </summary>
public async Task<bool> ConfigureDaqListAsync(DaqListConfig config, CancellationToken ct = default)
{
try
{
// 1. FREE_DAQ - 释放现有 DAQ 资源
var resp = await SendCommandAsync(XcpCommand.FREE_DAQ, Array.Empty<byte>(), ct);
if (resp == null || resp[0] != XcpResponseCode.POSITIVE)
return false;
// 2. ALLOC_DAQ - 分配 DAQ 列表
var allocParam = new byte[2];
allocParam[0] = (byte)(config.DaqListNumber >> 8);
allocParam[1] = (byte)(config.DaqListNumber & 0xFF);
resp = await SendCommandAsync(XcpCommand.ALLOC_DAQ, allocParam, ct);
if (resp == null || resp[0] != XcpResponseCode.POSITIVE)
return false;
// 3. SET_DAQ_PTR + WRITE_DAQ - 写入 ODT 条目
for (int i = 0; i < config.OdEntries.Count; i++)
{
var entry = config.OdEntries[i];
// SET_DAQ_PTR: [PID][0x00][DAQ_LIST_NUMBER(2)][ODT_NUMBER(1)][ODT_ENTRY_NUMBER(1)]
var ptrParam = new byte[5];
ptrParam[0] = 0x00;
ptrParam[1] = (byte)(config.DaqListNumber >> 8);
ptrParam[2] = (byte)(config.DaqListNumber & 0xFF);
ptrParam[3] = 0x00; // ODT 编号(简化:每个信号一个 ODT)
ptrParam[4] = (byte)i;
resp = await SendCommandAsync(XcpCommand.SET_DAQ_PTR, ptrParam, ct);
if (resp == null || resp[0] != XcpResponseCode.POSITIVE)
return false;
// WRITE_DAQ: [PID][BIT_OFFSET][SIZE][ADDR_EXT][ADDRESS(4bytes)]
var writeParam = new byte[7];
writeParam[0] = 0x00; // BIT_OFFSET
writeParam[1] = entry.Length;
writeParam[2] = entry.AddressExtension;
writeParam[3] = (byte)((entry.Address >> 24) & 0xFF);
writeParam[4] = (byte)((entry.Address >> 16) & 0xFF);
writeParam[5] = (byte)((entry.Address >> 8) & 0xFF);
writeParam[6] = (byte)(entry.Address & 0xFF);
resp = await SendCommandAsync(XcpCommand.WRITE_DAQ, writeParam, ct);
if (resp == null || resp[0] != XcpResponseCode.POSITIVE)
return false;
}
// 4. SET_DAQ_LIST_MODE: [PID][MODE][DAQ_LIST_NUMBER(2)][EVENT_CHANNEL(2)][TRANS_RATE_PRESCALER(2)][0x00][PRIORITY]
var modeParam = new byte[8];
modeParam[0] = config.Mode;
modeParam[1] = (byte)(config.DaqListNumber >> 8);
modeParam[2] = (byte)(config.DaqListNumber & 0xFF);
modeParam[3] = (byte)(config.EventChannel >> 8);
modeParam[4] = (byte)(config.EventChannel & 0xFF);
modeParam[5] = (byte)(config.Prescaler >> 8);
modeParam[6] = (byte)(config.Prescaler & 0xFF);
modeParam[7] = 0x00;
resp = await SendCommandAsync(XcpCommand.SET_DAQ_LIST_MODE, modeParam, ct);
return resp != null && resp[0] == XcpResponseCode.POSITIVE;
}
catch (Exception ex)
{
Debug.WriteLine($"XCP 配置 DAQ 列表失败: {ex.Message}");
return false;
}
}
/// <summary>
/// START_STOP_DAQ 命令 - 启动或停止 DAQ 列表采集
/// </summary>
/// <param name="start">true=启动,false=停止</param>
/// <param name="daqListNumber">DAQ 列表编号(0xFFFF 表示所有列表)</param>
public async Task<bool> StartStopDaqAsync(bool start, ushort daqListNumber = 0xFFFF, CancellationToken ct = default)
{
// START_STOP_DAQ: [PID][MODE][DAQ_LIST_NUMBER(2)]
var param = new byte[3];
param[0] = start ? (byte)0x01 : (byte)0x00;
param[1] = (byte)(daqListNumber >> 8);
param[2] = (byte)(daqListNumber & 0xFF);
var response = await SendCommandAsync(XcpCommand.START_STOP_DAQ, param, ct);
if (response != null && response[0] == XcpResponseCode.POSITIVE)
{
_state = start ? XcpSessionState.DaqRunning : XcpSessionState.Connected;
return true;
}
return false;
}
// ============================================================
// DTO 数据接收
// ============================================================
/// <summary>
/// 接收一帧 DTO 数据(DAQ 采集数据)
/// </summary>
/// <param name="timeoutMs">超时时间</param>
/// <param name="ct">取消令牌</param>
/// <returns>DTO 帧数据</returns>
public async Task<byte[]?> ReceiveDtoAsync(int timeoutMs = 1000, CancellationToken ct = default)
{
try
{
return await _transport.ReceiveDtoAsync(timeoutMs, ct);
}
catch (TimeoutException)
{
return null;
}
}
// ============================================================
// 连接管理
// ============================================================
/// <summary>
/// 完全断开(协议层 DISCONNECT + 传输层 Close)
/// </summary>
public async Task FullDisconnectAsync(CancellationToken ct = default)
{
try
{
if (_state != XcpSessionState.Disconnected)
await DisconnectAsync(ct);
}
finally
{
_transport.Close();
_state = XcpSessionState.Disconnected;
}
}
// ============================================================
// 内部核心方法
// ============================================================
/// <summary>
/// 发送 CTO 命令并等待响应(线程安全)
/// </summary>
private async Task<byte[]> SendCommandAsync(byte commandCode, byte[] parameters, CancellationToken ct)
{
lock (_cmdLock)
{
// 串行化命令发送,防止并发命令互相干扰
}
var frame = BuildCtoFrame(commandCode, parameters);
await _transport.SendCtoAsync(frame, ct);
var response = await _transport.ReceiveCtoAsync(_config.CommandTimeoutMs, ct);
return ValidateResponse(response, commandCode);
}
/// <summary>
/// 构造 CTO 帧: [PID(1byte)][参数...]
/// 如果从站 CTO 大小大于帧长度,需填充到 MaxCtoSize
/// </summary>
private byte[] BuildCtoFrame(byte pid, byte[] parameters)
{
int frameSize = 1 + parameters.Length;
int maxCto = SlaveInfo.MaxCtoSize > 0 ? SlaveInfo.MaxCtoSize : frameSize;
int actualSize = Math.Max(frameSize, maxCto);
var frame = new byte[actualSize];
frame[0] = pid;
if (parameters.Length > 0)
Array.Copy(parameters, 0, frame, 1, parameters.Length);
return frame;
}
/// <summary>
/// 校验响应帧,处理负响应
/// </summary>
private byte[] ValidateResponse(byte[] response, byte expectedCommand)
{
if (response == null || response.Length < 1)
throw new TimeoutException("XCP 响应为空");
if (response[0] == XcpResponseCode.POSITIVE)
return response;
if (response[0] == XcpResponseCode.NEGATIVE)
{
byte errorCode = response.Length > 1 ? response[1] : (byte)0xFF;
string errorDesc = GetErrorDescription(errorCode);
throw new XcpProtocolException(
$"XCP 命令 0x{expectedCommand:X2} 返回负响应: ERR=0x{errorCode:X2} ({errorDesc})",
errorCode);
}
// 非预期的 PID,返回原始响应让上层处理
return response;
}
/// <summary>
/// 解析 CONNECT 正响应
/// 响应格式: [PID][RESOURCE][COMM_MODE_BASIC][MAX_CTO][MAX_DTO(2)][TRANSPORT_VERSION][PROTOCOL_VERSION]
/// </summary>
private XcpSlaveInfo ParseConnectResponse(byte[] response)
{
var info = new XcpSlaveInfo();
if (response.Length >= 2) info.Resource = response[1];
if (response.Length >= 3) info.CommModeBasic = response[2];
if (response.Length >= 4) info.MaxCtoSize = response[3];
if (response.Length >= 6) info.MaxDtoSize = (ushort)((response[4] << 8) | response[5]);
if (response.Length >= 7) info.TransportLayerVersion = response[6];
if (response.Length >= 8) info.ProtocolVersion = response[7];
return info;
}
/// <summary>
/// 获取错误码的文字描述
/// </summary>
private static string GetErrorDescription(byte errorCode)
{
return errorCode switch
{
XcpErrorCode.ERR_CMD_UNKNOWN => "未知命令或命令被禁用",
XcpErrorCode.ERR_CMD_BUSY => "命令繁忙",
XcpErrorCode.ERR_DAQ_CONFIG => "DAQ 配置错误",
XcpErrorCode.ERR_OUT_OF_RANGE => "参数超出范围",
XcpErrorCode.ERR_MODE_NOT_VALID => "当前模式无效",
XcpErrorCode.ERR_SEGMENT_NOT_VALID => "段无效",
XcpErrorCode.ERR_SEQUENCE => "序列号错误",
XcpErrorCode.ERR_ACCESS_DENIED => "内存访问被拒绝",
XcpErrorCode.ERR_PAGE_NOT_VALID => "页无效",
_ => $"未知错误(0x{errorCode:X2})"
};
}
}
/// <summary>
/// XCP 协议异常
/// </summary>
public class XcpProtocolException : Exception
{
/// <summary>XCP 错误码</summary>
public byte ErrorCode { get; }
public XcpProtocolException(string message, byte errorCode) : base(message)
{
ErrorCode = errorCode;
}
}
}
+139
View File
@@ -0,0 +1,139 @@
namespace XCPCore
{
/// <summary>
/// XCP 命令码定义(参照 ASAM XCP 规范)
/// </summary>
public static class XcpCommand
{
// ===== 标准命令 =====
/// <summary>建立与从站的逻辑连接</summary>
public const byte CONNECT = 0xFF;
/// <summary>断开与从站的逻辑连接</summary>
public const byte DISCONNECT = 0xFE;
/// <summary>获取从站状态</summary>
public const byte GET_STATUS = 0xFD;
/// <summary>同步命令(用于恢复通信)</summary>
public const byte SYNCH = 0xFC;
/// <summary>获取从站 COMM_MODE 支持的详细信息</summary>
public const byte GET_COMM_MODE_INFO = 0xFB;
/// <summary>获取从站 ID(用于识别设备)</summary>
public const byte GET_ID = 0xFA;
/// <summary>设置从站 MTU(最大传输单元)</summary>
public const byte SET_MTA = 0xF6;
/// <summary>从从站上传数据(需要先 SET_MTA 设置地址)</summary>
public const byte UPLOAD = 0xF5;
/// <summary>短上传(地址 + 数据在同一帧中)</summary>
public const byte SHORT_UPLOAD = 0xF4;
/// <summary>向从站下载数据</summary>
public const byte DOWNLOAD = 0xF0;
/// <summary>下载数据(下一块,用于大数据块分页传输)</summary>
public const byte DOWNLOAD_NEXT = 0xEF;
/// <summary>下载数据(最大模式)</summary>
public const byte DOWNLOAD_MAX = 0xEE;
/// <summary>修改源地址(用于复制操作)</summary>
public const byte MODIFY_BITS = 0xED;
/// <summary>设置校准页</summary>
public const byte SET_CAL_PAGE = 0xEB;
/// <summary>获取校准页</summary>
public const byte GET_CAL_PAGE = 0xEA;
/// <summary>获取处理器信息</summary>
public const byte GET_PROCESSOR_INFO = 0xE9;
// ===== DAQ 命令 =====
/// <summary>设置 DAQ 列表指针(选择 DAQ/O DT/ODT 条目)</summary>
public const byte SET_DAQ_PTR = 0xE2;
/// <summary>写入 DAQ 列表条目</summary>
public const byte WRITE_DAQ = 0xE1;
/// <summary>设置 DAQ 列表条目数量</summary>
public const byte SET_DAQ_LIST_MODE = 0xE0;
/// <summary>启动/停止 DAQ 列表采集</summary>
public const byte START_STOP_DAQ = 0xDE;
/// <summary>释放 DAQ 列表资源</summary>
public const byte FREE_DAQ = 0xD9;
/// <summary>分配 DAQ 列表资源</summary>
public const byte ALLOC_DAQ = 0xD8;
/// <summary>获取 DAQ 列表信息</summary>
public const byte GET_DAQ_LIST_INFO = 0xE3;
/// <summary>获取 DAQ 事件信息</summary>
public const byte GET_DAQ_EVENT_INFO = 0xD7;
/// <summary>清除 DAQ 列表</summary>
public const byte CLEAR_DAQ_LIST = 0xDB;
}
/// <summary>
/// XCP 响应 PID 码
/// </summary>
public static class XcpResponseCode
{
/// <summary>正响应(命令执行成功)</summary>
public const byte POSITIVE = 0xFF;
/// <summary>负响应(命令执行失败,紧跟错误码)</summary>
public const byte NEGATIVE = 0xFE;
/// <summary>事件帧(DAQ 数据传输)</summary>
public const byte EVENT = 0xFD;
}
/// <summary>
/// XCP 错误码
/// </summary>
public static class XcpErrorCode
{
/// <summary>未知命令或命令被禁用</summary>
public const byte ERR_CMD_UNKNOWN = 0x10;
/// <summary>命令繁忙(上一个命令尚未完成)</summary>
public const byte ERR_CMD_BUSY = 0x12;
/// <summary>DAQ 配置错误</summary>
public const byte ERR_DAQ_CONFIG = 0x26;
/// <summary>参数超出范围</summary>
public const byte ERR_OUT_OF_RANGE = 0x22;
/// <summary>当前模式无效</summary>
public const byte ERR_MODE_NOT_VALID = 0x24;
/// <summary>段无效</summary>
public const byte ERR_SEGMENT_NOT_VALID = 0x25;
/// <summary>序列号错误(用于 DOWNLOAD_NEXT)</summary>
public const byte ERR_SEQUENCE = 0x20;
/// <summary>内存访问被拒绝</summary>
public const byte ERR_ACCESS_DENIED = 0x23;
/// <summary>页无效</summary>
public const byte ERR_PAGE_NOT_VALID = 0x21;
}
/// <summary>
/// XCP 从站信息(CONNECT 响应解析结果)
/// </summary>
public class XcpSlaveInfo
{
/// <summary>基础通信模式</summary>
public byte CommModeBasic { get; set; }
/// <summary>最大 CTO 帧大小(字节)</summary>
public byte MaxCtoSize { get; set; }
/// <summary>最大 DTO 帧大小(字节)</summary>
public ushort MaxDtoSize { get; set; }
/// <summary>传输层版本号</summary>
public byte TransportLayerVersion { get; set; }
/// <summary>协议版本号</summary>
public byte ProtocolVersion { get; set; }
/// <summary>从站支持的功能(RESOURCE 字段)</summary>
public byte Resource { get; set; }
/// <summary>是否支持 CAL(标定)</summary>
public bool SupportsCal => (Resource & 0x01) != 0;
/// <summary>是否支持 DAQ(数据采集)</summary>
public bool SupportsDaq => (Resource & 0x04) != 0;
/// <summary>是否支持 STIM(刺激)</summary>
public bool SupportsStim => (Resource & 0x08) != 0;
}
/// <summary>
/// XCP 会话状态枚举
/// </summary>
public enum XcpSessionState
{
/// <summary>未连接</summary>
Disconnected,
/// <summary>已连接(CONNECT 成功)</summary>
Connected,
/// <summary>DAQ 正在运行</summary>
DaqRunning
}
}
+214
View File
@@ -0,0 +1,214 @@
using System;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace XCPCore
{
/// <summary>
/// XCP over UDP 传输层实现。
/// 使用两个独立的 UdpClient:一个用于 CTO(命令/响应),一个用于 DTO(DAQ 数据)。
/// 复用现有 <c>Udp.cs</c> 中的超时模式(Task.WhenAny + Task.Delay)。
/// </summary>
public class XcpUdpTransport : IXcpTransport
{
private UdpClient? _ctoClient;
private UdpClient? _dtoClient;
private IPEndPoint? _ctoEndpoint;
private IPEndPoint? _dtoEndpoint;
private ushort _ctr; // 标准头包计数器
/// <summary>远端 IP 地址</summary>
public string RemoteIp { get; set; } = "127.0.0.1";
/// <summary>CTO 端口(命令/响应通道,默认 5555)</summary>
public int CtoPort { get; set; } = 5555;
/// <summary>DTO 端口(DAQ 数据通道,默认 5556)</summary>
public int DtoPort { get; set; } = 5556;
/// <summary>本地绑定端口(0=系统自动分配)</summary>
public int LocalPort { get; set; } = 0;
/// <summary>发送/接收超时时间(毫秒)</summary>
public int TimeoutMs { get; set; } = 3000;
/// <summary>
/// 是否使用 ASAM XCP on UDP 标准头(LEN + CTR,共 4 字节,大端序)。
/// 标准头格式: [LEN(2字节,大端)][CTR(2字节,大端)][XCP包...]
/// 部分从站(如 Vector CANoe/CANape、HostPRO)要求此头,部分从站不需要。
/// </summary>
public bool UseHeader { get; set; } = false;
/// <summary>传输层是否已打开</summary>
public bool IsOpen { get; private set; }
/// <summary>
/// 打开 UDP 传输层(创建并配置 UdpClient)
/// </summary>
public Task<bool> OpenAsync(CancellationToken ct = default)
{
if (IsOpen) return Task.FromResult(true);
try
{
// CTO 通道
_ctoClient = LocalPort > 0
? new UdpClient(LocalPort)
: new UdpClient();
_ctoClient.Client.SendTimeout = TimeoutMs;
_ctoClient.Client.ReceiveTimeout = TimeoutMs;
// DTO 通道
_dtoClient = new UdpClient();
_dtoClient.Client.SendTimeout = TimeoutMs;
_dtoClient.Client.ReceiveTimeout = TimeoutMs;
_ctoEndpoint = new IPEndPoint(IPAddress.Parse(RemoteIp), CtoPort);
_dtoEndpoint = new IPEndPoint(IPAddress.Parse(RemoteIp), DtoPort);
IsOpen = true;
Debug.WriteLine($"XCP UDP 传输层已打开: {RemoteIp}, CTO={CtoPort}, DTO={DtoPort}");
return Task.FromResult(true);
}
catch (Exception ex)
{
Debug.WriteLine($"XCP UDP 传输层打开失败: {ex.Message}");
Close();
return Task.FromResult(false);
}
}
/// <summary>
/// 发送 CTO 帧
/// </summary>
public async Task SendCtoAsync(byte[] data, CancellationToken ct = default)
{
if (_ctoClient == null || _ctoEndpoint == null)
throw new InvalidOperationException("XCP UDP CTO 通道未初始化");
var packet = UseHeader ? WrapWithHeader(data) : data;
var timeoutTask = Task.Delay(TimeoutMs, ct);
var sendTask = _ctoClient.SendAsync(packet, packet.Length, _ctoEndpoint);
var completed = await Task.WhenAny(sendTask, timeoutTask);
if (completed == timeoutTask)
throw new TimeoutException($"XCP UDP CTO 发送超时 ({TimeoutMs}ms)");
await sendTask;
}
/// <summary>
/// 接收 CTO 帧(带超时控制)
/// </summary>
public async Task<byte[]> ReceiveCtoAsync(int timeoutMs, CancellationToken ct = default)
{
if (_ctoClient == null)
throw new InvalidOperationException("XCP UDP CTO 通道未初始化");
var receiveTask = _ctoClient.ReceiveAsync();
var timeoutTask = Task.Delay(timeoutMs, ct);
var completed = await Task.WhenAny(receiveTask, timeoutTask);
if (completed == timeoutTask)
throw new TimeoutException($"XCP UDP CTO 接收超时 ({timeoutMs}ms)");
var result = await receiveTask;
return UseHeader ? StripHeader(result.Buffer) : result.Buffer;
}
/// <summary>
/// 发送 DTO 帧
/// </summary>
public async Task SendDtoAsync(byte[] data, CancellationToken ct = default)
{
if (_dtoClient == null || _dtoEndpoint == null)
throw new InvalidOperationException("XCP UDP DTO 通道未初始化");
var packet = UseHeader ? WrapWithHeader(data) : data;
var timeoutTask = Task.Delay(TimeoutMs, ct);
var sendTask = _dtoClient.SendAsync(packet, packet.Length, _dtoEndpoint);
var completed = await Task.WhenAny(sendTask, timeoutTask);
if (completed == timeoutTask)
throw new TimeoutException($"XCP UDP DTO 发送超时 ({TimeoutMs}ms)");
await sendTask;
}
/// <summary>
/// 接收 DTO 帧(带超时控制)
/// </summary>
public async Task<byte[]> ReceiveDtoAsync(int timeoutMs, CancellationToken ct = default)
{
if (_dtoClient == null)
throw new InvalidOperationException("XCP UDP DTO 通道未初始化");
var receiveTask = _dtoClient.ReceiveAsync();
var timeoutTask = Task.Delay(timeoutMs, ct);
var completed = await Task.WhenAny(receiveTask, timeoutTask);
if (completed == timeoutTask)
throw new TimeoutException($"XCP UDP DTO 接收超时 ({timeoutMs}ms)");
var result = await receiveTask;
return UseHeader ? StripHeader(result.Buffer) : result.Buffer;
}
/// <summary>
/// 关闭传输层(释放 UdpClient 资源)
/// </summary>
public void Close()
{
_ctoClient?.Close();
_dtoClient?.Close();
_ctoClient?.Dispose();
_dtoClient?.Dispose();
_ctoClient = null;
_dtoClient = null;
_ctoEndpoint = null;
_dtoEndpoint = null;
IsOpen = false;
Debug.WriteLine("XCP UDP 传输层已关闭");
}
// ============================================================
// 标准头处理(ASAM XCP on UDP: LEN + CTR)
// ============================================================
/// <summary>
/// 为 XCP 包添加标准头: [LEN(2字节,大端)][CTR(2字节,大端)][XCP包...]
/// LEN = XCP 包长度(不含头本身),CTR = 递增包计数器。
/// 字节序采用大端(网络字节序),与 ASAM XCP on UDP 及主流从站实现一致。
/// </summary>
private byte[] WrapWithHeader(byte[] xcpPacket)
{
var packet = new byte[4 + xcpPacket.Length];
// LEN: XCP 包长度(大端序 / 网络字节序)
packet[0] = (byte)((xcpPacket.Length >> 8) & 0xFF);
packet[1] = (byte)(xcpPacket.Length & 0xFF);
// CTR: 包计数器(大端序,每次发送递增,ushort 自动回绕)
packet[2] = (byte)((_ctr >> 8) & 0xFF);
packet[3] = (byte)(_ctr & 0xFF);
_ctr++;
// XCP 包本体
Array.Copy(xcpPacket, 0, packet, 4, xcpPacket.Length);
return packet;
}
/// <summary>
/// 剥离标准头(前 4 字节),返回 XCP 包本体。
/// 若数据不足 4 字节则原样返回(容错)。
/// </summary>
private static byte[] StripHeader(byte[] data)
{
if (data.Length <= 4)
return data;
var xcpPacket = new byte[data.Length - 4];
Array.Copy(data, 4, xcpPacket, 0, xcpPacket.Length);
return xcpPacket;
}
}
}