Compare commits
12
Commits
c017efdb49
...
9cb139b208
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb139b208 | ||
|
|
e877dc9a90 | ||
|
|
d9052861f5 | ||
|
|
6bbca727ca | ||
|
|
12b532382d | ||
|
|
981bc1e558 | ||
|
|
a2a62a0327 | ||
|
|
59d0c24deb | ||
|
|
cd52ca64af | ||
|
|
691fb62385 | ||
|
|
d84f2a874b | ||
|
|
8dd08fcc7a |
@@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TSMasterCAN", "TSMasterCAN\
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerAdaptabilityModule", "PowerAdaptabilityModule\PowerAdaptabilityModule.csproj", "{CCA6AA94-6105-4C0B-BCA9-D0712B1A7B89}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -150,8 +162,8 @@ Global
|
||||
{850A5BE8-0BC1-4D6A-8351-DD812C9C4427} = {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}
|
||||
{84006890-9288-44C4-9D17-FA88F3FDECFA} = {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}
|
||||
{1296318F-BD2F-4650-B16E-656BA04E80C0} = {1E92D601-68FC-45F6-8251-BD0F39226E5B}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<ProjectReference Include="..\SettingModule\SettingModule.csproj" />
|
||||
<ProjectReference Include="..\TestingModule\TestingModule.csproj" />
|
||||
<ProjectReference Include="..\TSMasterCAN\TSMasterCAN.csproj" />
|
||||
<ProjectReference Include="..\TSMasterXCPModule\TSMasterXCPModule.csproj" />
|
||||
<ProjectReference Include="..\UIShare\UIShare.csproj" />
|
||||
<ProjectReference Include="..\UpdateInfoMoudle\UpdateInfoMoudle.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+20
-15
@@ -49,11 +49,15 @@ namespace ACP
|
||||
private void OnDispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
LoggerHelper.Error(e.Exception.Message, e.Exception.StackTrace);
|
||||
// 必须标记已处理,否则异常在日志后继续传播,进程照样闪退
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
LoggerHelper.Error(e.Exception.Message, e.Exception.StackTrace);
|
||||
// 必须标记已观察,否则终结器线程会重新抛出该异常并导致进程闪退
|
||||
e.SetObserved();
|
||||
}
|
||||
|
||||
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
@@ -134,21 +138,22 @@ namespace ACP
|
||||
}
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
if (System.Diagnostics.Debugger.IsAttached)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
return;
|
||||
}
|
||||
string myMachineCode = MachineCodeHelper.GetDeviceMachineCode();
|
||||
if (MachineCodeHelper.VerifyMachineCode(myMachineCode))
|
||||
{
|
||||
base.OnStartup(e);
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageBox.Show("机器码不匹配!");
|
||||
return;
|
||||
}
|
||||
base.OnStartup(e);
|
||||
//if (System.Diagnostics.Debugger.IsAttached)
|
||||
//{
|
||||
// base.OnStartup(e);
|
||||
// return;
|
||||
//}
|
||||
//string myMachineCode = MachineCodeHelper.GetDeviceMachineCode();
|
||||
//if (MachineCodeHelper.VerifyMachineCode(myMachineCode))
|
||||
//{
|
||||
// base.OnStartup(e);
|
||||
//}
|
||||
//else
|
||||
//{
|
||||
// MessageBox.Show("机器码不匹配!");
|
||||
// return;
|
||||
//}
|
||||
|
||||
}
|
||||
protected override void RegisterRequiredTypes(IContainerRegistry containerRegistry)
|
||||
|
||||
@@ -906,6 +906,10 @@ namespace ACP.ViewModels
|
||||
if (_globalInfo.CurrentScope != "default") return;
|
||||
_regionManager.RequestNavigate("ShellViewManager", "PowerAdaptabilityView");
|
||||
break;
|
||||
case "TSMaster XCP 测试":
|
||||
if (_globalInfo.CurrentScope != "default") return;
|
||||
_regionManager.RequestNavigate("ShellViewManager", "TSMasterXCPView");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,11 @@
|
||||
CommandParameter="{Binding Content, RelativeSource={RelativeSource Self}}"
|
||||
Style="{StaticResource MaterialDesignFlatButton}"
|
||||
Margin="8" />
|
||||
<Button Content="TSMaster XCP 测试"
|
||||
Command="{Binding NavigateCommand}"
|
||||
CommandParameter="{Binding Content, RelativeSource={RelativeSource Self}}"
|
||||
Style="{StaticResource MaterialDesignFlatButton}"
|
||||
Margin="8" />
|
||||
</StackPanel>
|
||||
</materialDesign:DrawerHost.LeftDrawerContent>
|
||||
<Grid>
|
||||
|
||||
@@ -315,9 +315,10 @@ namespace CANModule.ViewModels
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_dialogService.ShowDialog("MessageDialog", new DialogParameters
|
||||
_dialogService.ShowDialog("MessageBox", new DialogParameters
|
||||
{
|
||||
{ "message", $"报文转换失败:{ex.Message}" }
|
||||
{ "Message", $"报文转换失败:{ex.Message}" },
|
||||
{ "ShowOk", true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -343,16 +344,43 @@ namespace CANModule.ViewModels
|
||||
|
||||
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;
|
||||
var message = SelectedMessage.Split(']')[1];
|
||||
var re = CAN.SetSignalValue(channel, message, SelectedSignal, SValue, IsDirectlySend, sendPeriod);
|
||||
ShowMessage("发送参数无效:请检查发送周期、信号值和发送通道。");
|
||||
return;
|
||||
}
|
||||
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()
|
||||
@@ -363,6 +391,29 @@ namespace CANModule.ViewModels
|
||||
private void InitTSMaster()
|
||||
{
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
await _commLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
EnsureConnected();
|
||||
return await Task.Run(() => _session!.RawIO.Read(length), ct);
|
||||
return await Task.Run(() => ReadExactBytes(length), ct);
|
||||
}
|
||||
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)
|
||||
{
|
||||
await _commLock.WaitAsync(ct);
|
||||
|
||||
+1261
-194
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ namespace DeviceCommand.Devices
|
||||
{
|
||||
/// <summary>
|
||||
/// 交流源 一拖三 (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>
|
||||
[ACPCommand]
|
||||
public class Chroma61800 : Tcp
|
||||
@@ -86,15 +88,24 @@ namespace DeviceCommand.Devices
|
||||
return await WriteReadAsync($"*IDN?{ScpiDelimiter}", ScpiDelimiter, ct);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 命令流延时 (*D)
|
||||
/// <para>在 SCPI 命令流中插入一段延时(单位: ms),常用于 LIST 参数下发完成后、切换 OUTP:MODE 之前等待仪器就绪</para>
|
||||
/// 退出远程状态、回到本地 (SYST:LOC,即 SYSTem:LOCal)
|
||||
/// <para>设定后仪器回到 LOCAL 状态,前面板恢复可用。手册注明本命令僅可用於 RS-232 控制之下,
|
||||
/// 经 Ethernet/USB 发送时仪器可能不响应</para>
|
||||
/// </summary>
|
||||
/// <param name="milliseconds">延时时长 (ms)</param>
|
||||
public virtual async Task 延时Async(double milliseconds, CancellationToken ct = default)
|
||||
public virtual async Task 退出远程Async(CancellationToken ct = default)
|
||||
{
|
||||
string valStr = milliseconds.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
await SendAsync($"*D {valStr}{ScpiDelimiter}", ct);
|
||||
await SendAsync($"SYST:LOC{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
|
||||
@@ -136,7 +147,7 @@ namespace DeviceCommand.Devices
|
||||
/// <summary>
|
||||
/// 设置交流电压 (VOLT:AC)
|
||||
/// </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)
|
||||
{
|
||||
string valStr = voltage.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
@@ -146,6 +157,7 @@ namespace DeviceCommand.Devices
|
||||
/// <summary>
|
||||
/// 设置直流电压 (VOLT:DC)
|
||||
/// </summary>
|
||||
/// <param name="voltage">电压值 (-495.0 ~ 495.0V)</param>
|
||||
public virtual async Task 设置直流电压Async(double voltage, CancellationToken ct = default)
|
||||
{
|
||||
string valStr = voltage.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
@@ -155,7 +167,7 @@ namespace DeviceCommand.Devices
|
||||
/// <summary>
|
||||
/// 设置输出频率 (FREQ)
|
||||
/// </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)
|
||||
{
|
||||
string valStr = frequency.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
@@ -163,8 +175,11 @@ namespace DeviceCommand.Devices
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置波形选择 (FUNC:SHAP:A SINE / SQUA / CSIN 等)
|
||||
/// 设置波形 A 组形状 (FUNC:SHAP:A)
|
||||
/// <para>冒号后的 A 是波形组别(仪器有 A/B 两组波形寄存器),不是波形形状;
|
||||
/// 三角波拼写为 TRIAN(TRIA/TRI 均非法,仪器只接受精确的 TRIAN)</para>
|
||||
/// </summary>
|
||||
/// <param name="shape">SINE | SQUA | TRIAN | CSIN | DST01~DST30(内建)| USR01~USR30(用户自定义)</param>
|
||||
public virtual async Task 设置波形Async(string shape, CancellationToken ct = default)
|
||||
{
|
||||
await SendAsync($"FUNC:SHAP:A {shape}{ScpiDelimiter}", ct);
|
||||
@@ -175,7 +190,7 @@ namespace DeviceCommand.Devices
|
||||
#region 4. 相位与多相模式控制 (INST & PHASE 子系统)
|
||||
|
||||
/// <summary>
|
||||
/// 设置单/三相工作模式 (INST:PHAS THREE / SINGLE)
|
||||
/// 设置单/三相工作模式 (INST:PHAS THREE / SINGLE / SPLIT)
|
||||
/// </summary>
|
||||
/// <param name="isThreePhase">true: 三相模式, false: 单相模式</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 设定列表点的静止时间顺序 (SOUR:LIST:DWEL)
|
||||
/// </summary>
|
||||
@@ -344,7 +369,7 @@ namespace DeviceCommand.Devices
|
||||
/// <summary>
|
||||
/// 设定波形缓冲区列表点数的顺序 (SOUR:LIST:SHAP)
|
||||
/// </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)
|
||||
{
|
||||
string valStr = string.Join(",", shapes);
|
||||
@@ -360,10 +385,28 @@ namespace DeviceCommand.Devices
|
||||
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>
|
||||
/// 设定 AC 起始电压列表点数的顺序 (SOUR:LIST:VOLT:AC:STAR)
|
||||
/// </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)
|
||||
{
|
||||
string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture)));
|
||||
@@ -373,7 +416,7 @@ namespace DeviceCommand.Devices
|
||||
/// <summary>
|
||||
/// 设定 AC 结束电压列表点数的顺序 (SOUR:LIST:VOLT:AC:END)
|
||||
/// </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)
|
||||
{
|
||||
string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture)));
|
||||
@@ -383,7 +426,7 @@ namespace DeviceCommand.Devices
|
||||
/// <summary>
|
||||
/// 设定 DC 起始电压列表点数的顺序 (SOUR:LIST:VOLT:DC:STAR)
|
||||
/// </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)
|
||||
{
|
||||
string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture)));
|
||||
@@ -393,7 +436,7 @@ namespace DeviceCommand.Devices
|
||||
/// <summary>
|
||||
/// 设定 DC 结束电压列表点数的顺序 (SOUR:LIST:VOLT:DC:END)
|
||||
/// </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)
|
||||
{
|
||||
string valStr = string.Join(",", Array.ConvertAll(voltages, v => v.ToString("0.###", CultureInfo.InvariantCulture)));
|
||||
@@ -403,7 +446,7 @@ namespace DeviceCommand.Devices
|
||||
/// <summary>
|
||||
/// 设定起始频率列表点数的顺序 (SOUR:LIST:FREQ:STAR)
|
||||
/// </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)
|
||||
{
|
||||
string valStr = string.Join(",", Array.ConvertAll(frequencies, v => v.ToString("0.##", CultureInfo.InvariantCulture)));
|
||||
@@ -413,7 +456,7 @@ namespace DeviceCommand.Devices
|
||||
/// <summary>
|
||||
/// 设定结束频率列表点数的顺序 (SOUR:LIST:FREQ:END)
|
||||
/// </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)
|
||||
{
|
||||
string valStr = string.Join(",", Array.ConvertAll(frequencies, v => v.ToString("0.##", CultureInfo.InvariantCulture)));
|
||||
@@ -509,7 +552,8 @@ namespace DeviceCommand.Devices
|
||||
|
||||
/// <summary>
|
||||
/// 开启/关闭谐波量测 (SENSe:HARMonic)
|
||||
/// <para>每次新的 Fetch/Measure 前必须先 ON,约需 3 秒得到结果;
|
||||
/// <para>每次新的 Fetch
|
||||
/// ./Measure 前必须先 ON,约需 3 秒得到结果;
|
||||
/// 若要量测其他数据,须先设回 OFF。分相量测配合 INST:COUP NONE + INST:NSEL 逐相进行</para>
|
||||
/// </summary>
|
||||
/// <param name="isOn">true: 开启谐波量测(ON); false: 关闭(OFF)</param>
|
||||
@@ -556,5 +600,144 @@ namespace DeviceCommand.Devices
|
||||
|
||||
#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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -11,26 +11,36 @@ using System.ComponentModel;
|
||||
namespace DeviceCommand.Devices
|
||||
{
|
||||
/// <summary>
|
||||
/// 系统控制功能类型枚举 (根据三工位 ACP接线图 更新)
|
||||
/// 系统控制功能类型枚举 (根据 ACP硬件接线图.xlsx 三工位系统更新)
|
||||
/// </summary>
|
||||
public enum 功能动作 : byte
|
||||
{
|
||||
产品充电三相,
|
||||
产品放电三相,
|
||||
/// <summary>低压电源产品唤醒通断 KL30(三工位,模块1# Y1/Y3/Y5,KA2/KA4/KA6)</summary>
|
||||
KL30通断,
|
||||
/// <summary>低压电源产品唤醒通断 KL15(三工位,模块1# Y2/Y4/Y6,KA3/KA5/KA7)</summary>
|
||||
KL15通断,
|
||||
/// <summary>电子锁60Ω电阻通断(三工位,模块1# Y13/Y14/Y15)</summary>
|
||||
电子锁60欧电阻,
|
||||
/// <summary>温度检测1KΩ电阻通断(三工位,模块3# Y1/Y2/Y3)</summary>
|
||||
温度检测1K欧电阻,
|
||||
/// <summary>CC信号串1KΩ电阻(三工位,模块2# Y1/Y5/Y9)</summary>
|
||||
CC信号串1K欧电阻,
|
||||
/// <summary>CP信号串1KΩ电阻(三工位,模块2# Y13/Y14/Y15)</summary>
|
||||
CP信号串1K欧电阻,
|
||||
/// <summary>CC信号串680Ω电阻(三工位,模块2# Y2/Y6/Y10)</summary>
|
||||
CC信号串680欧电阻,
|
||||
/// <summary>CC信号串220Ω电阻(三工位,模块2# Y3/Y7/Y11)</summary>
|
||||
CC信号串220欧电阻,
|
||||
/// <summary>CC信号串100Ω电阻(三工位,模块2# Y4/Y8/Y12)</summary>
|
||||
CC信号串100欧电阻,
|
||||
CC信号空载,
|
||||
/// <summary>蜂鸣器阈值报警(三工位,模块1# Y10/Y11/Y12)</summary>
|
||||
蜂鸣器报警,
|
||||
抛负载通断, // (接线图中标注于 Y14, KM8)
|
||||
短路测试HV, // (接线图中标注于 Y15, KM9)
|
||||
短路测试LV // (接线图中标注于 Y16, KM10)
|
||||
/// <summary>双向直流源(HV+)抛负载通断(仅一工位,模块1# Y7,KA8)</summary>
|
||||
抛负载通断,
|
||||
/// <summary>双向直流源(HV)短路测试通断(仅一工位,模块1# Y8,KA9)</summary>
|
||||
短路测试HV,
|
||||
/// <summary>低压源载一体机(LV)短路测试通断(仅一工位,模块1# Y9,KA10)</summary>
|
||||
短路测试LV
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -50,90 +60,75 @@ namespace DeviceCommand.Devices
|
||||
|
||||
/// <summary>
|
||||
/// IO 板卡组(三工位统一调度),封装 3 块 IO 继电器板卡的访问。
|
||||
/// 根据 ACP 硬件接线图的点位映射字典,按台架序号路由到对应板卡的线圈地址,
|
||||
/// 并内置充电/放电互锁保护,防止同时吸合造成短路。
|
||||
/// 根据 ACP硬件接线图.xlsx 的点位映射字典,按台架序号路由到对应板卡的线圈地址。
|
||||
/// 注意:模块1# 集中承载三工位的 KL30/KL15 唤醒、蜂鸣器、电子锁及一工位专属的
|
||||
/// 抛负载/短路测试;模块2# 承载三工位 CC/CP 引导信号电阻;模块3# 承载三工位温度检测电阻。
|
||||
/// </summary>
|
||||
[ACPCommand]
|
||||
public class IOBoardGroup
|
||||
{
|
||||
// 声明底层的 3 个 IO 板卡实例(对应三工位系统)
|
||||
// 声明底层的 3 个 IO 板卡实例(对应接线图中的 IO继电器模块 1#/2#/3#)
|
||||
public IOBoard Board1 { get; private set; }
|
||||
public IOBoard Board2 { get; private set; }
|
||||
public IOBoard Board3 { get; private set; }
|
||||
|
||||
private readonly byte _slaveAddress = 1; // 默认 Modbus 从站号为 1
|
||||
|
||||
// 核心:根据 ACP硬件接线图 严格对齐的数据字典 (3个模块独立对应1-3工位)
|
||||
// 核心:根据 ACP硬件接线图.xlsx 严格对齐的数据字典(按工位分组,点位跨模块路由)
|
||||
private static readonly Dictionary<int, Dictionary<功能动作, IoPortInfo>> IoMapping
|
||||
= new Dictionary<int, Dictionary<功能动作, IoPortInfo>>()
|
||||
{
|
||||
// 1# 工位 -> IO继电器模块 1#
|
||||
// 1# 工位(一工位)
|
||||
{ 1, new Dictionary<功能动作, IoPortInfo> {
|
||||
{ 功能动作.产品充电三相, new IoPortInfo(1, 0) }, // Y1 -> 0 (KM2)
|
||||
{ 功能动作.产品放电三相, new IoPortInfo(1, 1) }, // Y2 -> 1 (KM3)
|
||||
{ 功能动作.KL30通断, new IoPortInfo(1, 2) }, // Y3 -> 2
|
||||
{ 功能动作.KL15通断, new IoPortInfo(1, 3) }, // Y4 -> 3
|
||||
{ 功能动作.电子锁60欧电阻, new IoPortInfo(1, 4) }, // Y5 -> 4
|
||||
{ 功能动作.温度检测1K欧电阻, new IoPortInfo(1, 5) }, // Y6 -> 5
|
||||
{ 功能动作.CC信号串1K欧电阻, new IoPortInfo(1, 6) }, // Y7 -> 6
|
||||
{ 功能动作.CP信号串1K欧电阻, new IoPortInfo(1, 7) }, // Y8 -> 7
|
||||
{ 功能动作.CC信号串680欧电阻, new IoPortInfo(1, 8) }, // Y9 -> 8
|
||||
{ 功能动作.CC信号串220欧电阻, new IoPortInfo(1, 9) }, // Y10 -> 9
|
||||
{ 功能动作.CC信号串100欧电阻, new IoPortInfo(1, 10) }, // Y11 -> 10
|
||||
{ 功能动作.CC信号空载, new IoPortInfo(1, 11) }, // Y12 -> 11
|
||||
{ 功能动作.蜂鸣器报警, new IoPortInfo(1, 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)
|
||||
{ 功能动作.KL30通断, new IoPortInfo(1, 0) }, // 模块1# Y1 -> 0 (KA2, 低压电源KL30唤醒)
|
||||
{ 功能动作.KL15通断, new IoPortInfo(1, 1) }, // 模块1# Y2 -> 1 (KA3, 低压电源KL15唤醒)
|
||||
{ 功能动作.抛负载通断, new IoPortInfo(1, 6) }, // 模块1# Y7 -> 6 (KA8, 双向直流源HV+抛负载)
|
||||
{ 功能动作.短路测试HV, new IoPortInfo(1, 7) }, // 模块1# Y8 -> 7 (KA9, 双向直流源HV短路测试)
|
||||
{ 功能动作.短路测试LV, new IoPortInfo(1, 8) }, // 模块1# Y9 -> 8 (KA10, 低压源载一体机LV短路测试)
|
||||
{ 功能动作.蜂鸣器报警, new IoPortInfo(1, 9) }, // 模块1# Y10 -> 9 (1#蜂鸣器)
|
||||
{ 功能动作.电子锁60欧电阻, new IoPortInfo(1, 12) }, // 模块1# Y13 -> 12 (1#电子锁60Ω)
|
||||
{ 功能动作.CC信号串1K欧电阻, new IoPortInfo(2, 0) }, // 模块2# Y1 -> 0 (CC 1KΩ)
|
||||
{ 功能动作.CC信号串680欧电阻, new IoPortInfo(2, 1) }, // 模块2# Y2 -> 1 (CC 680Ω)
|
||||
{ 功能动作.CC信号串220欧电阻, new IoPortInfo(2, 2) }, // 模块2# Y3 -> 2 (CC 220Ω)
|
||||
{ 功能动作.CC信号串100欧电阻, new IoPortInfo(2, 3) }, // 模块2# Y4 -> 3 (CC 100Ω)
|
||||
{ 功能动作.CP信号串1K欧电阻, new IoPortInfo(2, 12) }, // 模块2# Y13 -> 12 (CP 1KΩ)
|
||||
{ 功能动作.温度检测1K欧电阻, new IoPortInfo(3, 0) } // 模块3# Y1 -> 0 (温度检测1KΩ)
|
||||
}},
|
||||
// 2# 工位 -> IO继电器模块 2#
|
||||
// 2# 工位(二工位)
|
||||
{ 2, new Dictionary<功能动作, IoPortInfo> {
|
||||
{ 功能动作.产品充电三相, new IoPortInfo(2, 0) }, // Y1 -> 0 (KM4)
|
||||
{ 功能动作.产品放电三相, new IoPortInfo(2, 1) }, // Y2 -> 1 (KM5)
|
||||
{ 功能动作.KL30通断, new IoPortInfo(2, 2) }, // Y3 -> 2
|
||||
{ 功能动作.KL15通断, new IoPortInfo(2, 3) }, // Y4 -> 3
|
||||
{ 功能动作.电子锁60欧电阻, new IoPortInfo(2, 4) }, // Y5 -> 4
|
||||
{ 功能动作.温度检测1K欧电阻, new IoPortInfo(2, 5) }, // Y6 -> 5
|
||||
{ 功能动作.CC信号串1K欧电阻, new IoPortInfo(2, 6) }, // Y7 -> 6
|
||||
{ 功能动作.CP信号串1K欧电阻, new IoPortInfo(2, 7) }, // Y8 -> 7
|
||||
{ 功能动作.CC信号串680欧电阻, new IoPortInfo(2, 8) }, // Y9 -> 8
|
||||
{ 功能动作.CC信号串220欧电阻, new IoPortInfo(2, 9) }, // Y10 -> 9
|
||||
{ 功能动作.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)
|
||||
{ 功能动作.KL30通断, new IoPortInfo(1, 2) }, // 模块1# Y3 -> 2 (KA4, 低压电源KL30唤醒)
|
||||
{ 功能动作.KL15通断, new IoPortInfo(1, 3) }, // 模块1# Y4 -> 3 (KA5, 低压电源KL15唤醒)
|
||||
{ 功能动作.蜂鸣器报警, new IoPortInfo(1, 10) }, // 模块1# Y11 -> 10 (2#蜂鸣器)
|
||||
{ 功能动作.电子锁60欧电阻, new IoPortInfo(1, 13) }, // 模块1# Y14 -> 13 (2#电子锁60Ω)
|
||||
{ 功能动作.CC信号串1K欧电阻, new IoPortInfo(2, 4) }, // 模块2# Y5 -> 4 (CC 1KΩ)
|
||||
{ 功能动作.CC信号串680欧电阻, new IoPortInfo(2, 5) }, // 模块2# Y6 -> 5 (CC 680Ω)
|
||||
{ 功能动作.CC信号串220欧电阻, new IoPortInfo(2, 6) }, // 模块2# Y7 -> 6 (CC 220Ω)
|
||||
{ 功能动作.CC信号串100欧电阻, new IoPortInfo(2, 7) }, // 模块2# Y8 -> 7 (CC 100Ω)
|
||||
{ 功能动作.CP信号串1K欧电阻, new IoPortInfo(2, 13) }, // 模块2# Y14 -> 13 (CP 1KΩ)
|
||||
{ 功能动作.温度检测1K欧电阻, new IoPortInfo(3, 1) } // 模块3# Y2 -> 1 (温度检测1KΩ)
|
||||
}},
|
||||
// 3# 工位 -> IO继电器模块 3# (包含额外的抛负载及短路测试控制)
|
||||
// 3# 工位(三工位)
|
||||
{ 3, new Dictionary<功能动作, IoPortInfo> {
|
||||
{ 功能动作.产品充电三相, new IoPortInfo(3, 0) }, // Y1 -> 0 (KM6)
|
||||
{ 功能动作.产品放电三相, new IoPortInfo(3, 1) }, // Y2 -> 1 (KM7)
|
||||
{ 功能动作.KL30通断, new IoPortInfo(3, 2) }, // Y3 -> 2
|
||||
{ 功能动作.KL15通断, new IoPortInfo(3, 3) }, // Y4 -> 3
|
||||
{ 功能动作.电子锁60欧电阻, new IoPortInfo(3, 4) }, // Y5 -> 4
|
||||
{ 功能动作.温度检测1K欧电阻, new IoPortInfo(3, 5) }, // Y6 -> 5
|
||||
{ 功能动作.CC信号串1K欧电阻, new IoPortInfo(3, 6) }, // Y7 -> 6
|
||||
{ 功能动作.CP信号串1K欧电阻, new IoPortInfo(3, 7) }, // Y8 -> 7
|
||||
{ 功能动作.CC信号串680欧电阻, new IoPortInfo(3, 8) }, // Y9 -> 8
|
||||
{ 功能动作.CC信号串220欧电阻, new IoPortInfo(3, 9) }, // Y10 -> 9
|
||||
{ 功能动作.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)
|
||||
{ 功能动作.KL30通断, new IoPortInfo(1, 4) }, // 模块1# Y5 -> 4 (KA6, 低压电源KL30唤醒)
|
||||
{ 功能动作.KL15通断, new IoPortInfo(1, 5) }, // 模块1# Y6 -> 5 (KA7, 低压电源KL15唤醒)
|
||||
{ 功能动作.蜂鸣器报警, new IoPortInfo(1, 11) }, // 模块1# Y12 -> 11 (3#蜂鸣器)
|
||||
{ 功能动作.电子锁60欧电阻, new IoPortInfo(1, 14) }, // 模块1# Y15 -> 14 (3#电子锁60Ω)
|
||||
{ 功能动作.CC信号串1K欧电阻, new IoPortInfo(2, 8) }, // 模块2# Y9 -> 8 (CC 1KΩ)
|
||||
{ 功能动作.CC信号串680欧电阻, new IoPortInfo(2, 9) }, // 模块2# Y10 -> 9 (CC 680Ω)
|
||||
{ 功能动作.CC信号串220欧电阻, new IoPortInfo(2, 10) }, // 模块2# Y11 -> 10 (CC 220Ω)
|
||||
{ 功能动作.CC信号串100欧电阻, new IoPortInfo(2, 11) }, // 模块2# Y12 -> 11 (CC 100Ω)
|
||||
{ 功能动作.CP信号串1K欧电阻, new IoPortInfo(2, 14) }, // 模块2# Y15 -> 14 (CP 1KΩ)
|
||||
{ 功能动作.温度检测1K欧电阻, new IoPortInfo(3, 2) } // 模块3# Y3 -> 2 (温度检测1KΩ)
|
||||
}}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数,注入三个工位的 IO 板卡实例。
|
||||
/// 构造函数,注入三个 IO 板卡实例。
|
||||
/// </summary>
|
||||
/// <param name="board1">1# 工位 IO 板卡实例</param>
|
||||
/// <param name="board2">2# 工位 IO 板卡实例</param>
|
||||
/// <param name="board3">3# 工位 IO 板卡实例</param>
|
||||
/// <param name="board1">IO继电器模块 1# 板卡实例</param>
|
||||
/// <param name="board2">IO继电器模块 2# 板卡实例</param>
|
||||
/// <param name="board3">IO继电器模块 3# 板卡实例</param>
|
||||
/// <exception cref="ArgumentNullException">任一块板卡实例为空时抛出</exception>
|
||||
// 修改构造函数以支持 3 个板块
|
||||
public IOBoardGroup(IOBoard board1, IOBoard board2, IOBoard board3)
|
||||
{
|
||||
Board1 = board1 ?? throw new ArgumentNullException(nameof(board1), "IOBoard Board1 实例不能为空。");
|
||||
@@ -142,7 +137,7 @@ namespace DeviceCommand.Devices
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行指定台架的功能动作控制(带高低电平开关及底层安全互锁)
|
||||
/// 执行指定台架的功能动作控制(按接线图字典跨模块路由到对应线圈地址)
|
||||
/// </summary>
|
||||
/// <param name="台架序号">台架/工位序号 (1 - 3)</param>
|
||||
/// <param name="动作">控制功能类型</param>
|
||||
@@ -153,29 +148,10 @@ namespace DeviceCommand.Devices
|
||||
// 1. 根据传入的台架序号和动作检索字典映射关系
|
||||
if (!IoMapping.TryGetValue(台架序号, out var 动作字典) || !动作字典.TryGetValue(动作, out var io信息))
|
||||
{
|
||||
throw new ArgumentException($"未找到台架 [{台架序号}] 对应功能 [{动作}] 的 IO 点位映射配置。请注意抛负载及短路测试仅存在于 3# 工位。");
|
||||
throw new ArgumentException($"未找到台架 [{台架序号}] 对应功能 [{动作}] 的 IO 点位映射配置。请注意抛负载及短路测试(HV/LV)仅存在于 1# 工位。");
|
||||
}
|
||||
|
||||
// 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. 动态获取目标板卡实例并直接驱动开关端口
|
||||
// 2. 动态获取目标板卡实例并直接驱动开关端口
|
||||
IOBoard 目标板卡 = GetBoardInstance(io信息.ModuleIndex);
|
||||
await 目标板卡.写输出开关(_slaveAddress, io信息.Address, 开关状态);
|
||||
}
|
||||
|
||||
+61
-196
@@ -2,7 +2,6 @@
|
||||
using DeviceCommand.Base;
|
||||
using Model.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -10,237 +9,120 @@ using System.Threading.Tasks;
|
||||
namespace DeviceCommand.Devices
|
||||
{
|
||||
/// <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>
|
||||
[ACPCommand]
|
||||
public class IT6720: Serial_Port // 直接继承您提供的 Serial_Port 基类
|
||||
public class IT6720 : Visa
|
||||
{
|
||||
// 帧格式:同步头 AA + 地址 + 命令字 + 数据[4-25] + 校验和,共 26 字节
|
||||
private const byte FrameSyncByte = 0xAA;
|
||||
private const int FrameLength = 26;
|
||||
private readonly byte _deviceAddress = 0x00; // 电源地址
|
||||
// SCPI 指令结束符(VISA 消息终止符使用 LF)
|
||||
private const string ScpiDelimiter = "\n";
|
||||
|
||||
public IT6720(SerialPortConfig config) : base(config)
|
||||
// 最近一次设定的限流值(A):IT67xx SCPI 无直接 CV/CC 查询,用"实测电流是否顶到限流"启发式判断输出模式
|
||||
private double _setCurrentLimit;
|
||||
|
||||
public IT6720(VisaConfig config) : base(config)
|
||||
{
|
||||
}
|
||||
|
||||
#region 核心协议封装(不变)
|
||||
#region SCPI 收发辅助
|
||||
|
||||
/// <summary>
|
||||
/// 构建 26 字节的发送帧,并计算校验和
|
||||
/// </summary>
|
||||
private byte[] BuildFrame(byte command, byte[] dataBytes)
|
||||
{
|
||||
List<byte> frame = new List<byte> { FrameSyncByte, _deviceAddress, command };
|
||||
/// <summary>以不变文化格式化数值,避免小数分隔符随系统区域变化。</summary>
|
||||
private static string Fmt(double value) => value.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
if (dataBytes != null && dataBytes.Length > 0)
|
||||
{
|
||||
frame.AddRange(dataBytes);
|
||||
}
|
||||
/// <summary>宽松解析仪器回传的数值字符串,失败返回 0。</summary>
|
||||
private static double ParseDouble(string raw)
|
||||
=> double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double v) ? v : 0;
|
||||
|
||||
// 数据区必须占满 23 字节 (索引 3 到 24)
|
||||
while (frame.Count < 25)
|
||||
{
|
||||
frame.Add(0x00);
|
||||
}
|
||||
/// <summary>发送设定类 SCPI 命令(只发不读:SCPI 设置命令无应答,读取会空等超时)。</summary>
|
||||
private async Task SetAsync(string command, CancellationToken ct)
|
||||
=> await SendAsync($"{command}{ScpiDelimiter}", ct).ConfigureAwait(false);
|
||||
|
||||
// 计算校验和:前 25 字节之和取低 8 位
|
||||
byte checkSum = 0;
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
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;
|
||||
}
|
||||
/// <summary>发送查询类 SCPI 命令并读取一行响应。</summary>
|
||||
private async Task<string> QueryAsync(string query, CancellationToken ct)
|
||||
=> await WriteReadAsync($"{query}{ScpiDelimiter}", ScpiDelimiter, ct).ConfigureAwait(false);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 1. 核心二进制收发方法(使用基类新方法)
|
||||
#region 1. 操作模式与输出控制
|
||||
|
||||
/// <summary>
|
||||
/// 发送命令帧并读取仪器返回的 26 字节数据帧
|
||||
/// </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 远程控制 / 面板控制
|
||||
/// 切换电源控制模式:PC 远程控制 (SYSTem:REMote) / 面板本地控制 (SYSTem:LOCal)
|
||||
/// </summary>
|
||||
public virtual async Task 切换远程控制模式(bool 远程控制 = true, CancellationToken ct = default)
|
||||
{
|
||||
byte[] data = new byte[] { (byte)(远程控制 ? 0x01 : 0x00) };
|
||||
byte[] response = await SendAndReadFrameAsync(0x20, data, ct);
|
||||
|
||||
if (!ValidateResponseFrame(response))
|
||||
throw new Exception("IT6720 切换控制模式时通讯校验失败。");
|
||||
}
|
||||
=> await SetAsync(远程控制 ? "SYSTem:REMote" : "SYSTem:LOCal", ct);
|
||||
|
||||
/// <summary>
|
||||
/// 切换为本地(面板)控制模式,与远程控制互斥
|
||||
/// </summary>
|
||||
/// <param name="ct">取消令牌</param>
|
||||
public virtual async Task 切换本地控制模式(CancellationToken ct = default)
|
||||
{
|
||||
await 切换远程控制模式(false, ct);
|
||||
}
|
||||
=> await 切换远程控制模式(false, ct);
|
||||
|
||||
/// <summary>
|
||||
/// 开启或关闭电源输出
|
||||
/// 开启或关闭电源输出 (OUTPut 1/0)
|
||||
/// </summary>
|
||||
public virtual async Task 设置输出开关(bool 开启 = true, CancellationToken ct = default)
|
||||
{
|
||||
byte[] data = new byte[] { (byte)(开启 ? 0x01 : 0x00) };
|
||||
byte[] response = await SendAndReadFrameAsync(0x21, data, ct);
|
||||
|
||||
if (!ValidateResponseFrame(response))
|
||||
throw new Exception("IT6720 设置输出时通讯校验失败。");
|
||||
}
|
||||
=> await SetAsync($"OUTPut {(开启 ? 1 : 0)}", ct);
|
||||
|
||||
/// <summary>
|
||||
/// 查询电源输出开关状态(由实时测量值的状态字节判断)
|
||||
/// 查询电源输出开关状态 (OUTPut?)
|
||||
/// </summary>
|
||||
/// <param name="ct">取消令牌</param>
|
||||
/// <returns>输出开关状态字符串 (ON / OFF)</returns>
|
||||
public virtual async Task<string> 查询输出开关状态(CancellationToken ct = default)
|
||||
{
|
||||
var (_, _, status) = await 查询实时测量值(ct);
|
||||
return (status & 0x01) != 0 ? "ON" : "OFF";
|
||||
string r = (await QueryAsync("OUTPut?", ct)).Trim();
|
||||
return (r == "1" || r.StartsWith("ON", StringComparison.OrdinalIgnoreCase)) ? "ON" : "OFF";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 3. 电压 / 电流设定
|
||||
#region 2. 电压 / 电流设定
|
||||
|
||||
/// <summary>
|
||||
/// 设置电源的电压上限
|
||||
/// 设置电源的电压上限 / 过压保护阈值 (VOLT:PROTection)
|
||||
/// </summary>
|
||||
public virtual async Task 设置电压上限(double 电压, CancellationToken ct = default)
|
||||
{
|
||||
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 设置电压上限时通讯校验失败。");
|
||||
}
|
||||
=> await SetAsync($"VOLT:PROTection {Fmt(电压)}", ct);
|
||||
|
||||
/// <summary>
|
||||
/// 设置电源的输出电压
|
||||
/// 设置电源的输出电压 (VOLTage)
|
||||
/// </summary>
|
||||
public virtual async Task 设置输出电压(double 电压, CancellationToken ct = default)
|
||||
{
|
||||
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 设置输出电压时通讯校验失败。");
|
||||
}
|
||||
=> await SetAsync($"VOLTage {Fmt(电压)}", ct);
|
||||
|
||||
/// <summary>
|
||||
/// 设置电源的输出电流限流值
|
||||
/// 设置电源的输出电流限流值 (CURRent)
|
||||
/// </summary>
|
||||
public virtual async Task 设置输出电流(double 电流, CancellationToken ct = default)
|
||||
{
|
||||
int raw = ConvertToProtocolValue(电流);
|
||||
byte[] data = new byte[]
|
||||
{
|
||||
(byte)(raw & 0xFF), (byte)((raw >> 8) & 0xFF)
|
||||
};
|
||||
byte[] response = await SendAndReadFrameAsync(0x24, data, ct);
|
||||
if (!ValidateResponseFrame(response))
|
||||
throw new Exception("IT6720 设置输出电流时通讯校验失败。");
|
||||
_setCurrentLimit = 电流;
|
||||
await SetAsync($"CURRent {Fmt(电流)}", ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 4. 读取实时数据与状态(对应 0x26H)
|
||||
#region 3. 读取实时数据与状态
|
||||
|
||||
/// <summary>
|
||||
/// 读取电源的实时电流、电压、设定值及电源状态
|
||||
/// 返回元组:实际电流(A), 实际电压(V), 状态字节(int)
|
||||
/// 读取电源的实时电流、电压及输出开关状态
|
||||
/// 返回元组:实际电流(A, MEASure:CURRent?), 实际电压(V, MEASure:VOLTage?), 状态字节(int, 位0=输出开关)
|
||||
/// </summary>
|
||||
[Monitorable("IT6720 实时测量数据")]
|
||||
public virtual async Task<(double ActualCurrent, double ActualVoltage, int StatusByte)> 查询实时测量值(CancellationToken ct = default)
|
||||
{
|
||||
byte[] response = await SendAndReadFrameAsync(0x26, new byte[0], ct);
|
||||
|
||||
if (!ValidateResponseFrame(response))
|
||||
throw new Exception("IT6720 读取数据时通讯校验失败。");
|
||||
|
||||
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);
|
||||
double current = ParseDouble(await QueryAsync("MEASure:CURRent?", ct));
|
||||
double voltage = ParseDouble(await QueryAsync("MEASure:VOLTage?", ct));
|
||||
string on = (await QueryAsync("OUTPut?", ct)).Trim();
|
||||
int statusByte = (on == "1" || on.StartsWith("ON", StringComparison.OrdinalIgnoreCase)) ? 0x01 : 0x00;
|
||||
return (current, voltage, statusByte);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询电源的实时实际输出电压 (MEASure 相关寄存器)
|
||||
/// 查询电源的实时实际输出电压 (MEASure:VOLTage?)
|
||||
/// </summary>
|
||||
/// <param name="ct">取消令牌</param>
|
||||
/// <returns>实时电压值 (单位: V,保留两位小数)</returns>
|
||||
@@ -252,7 +134,7 @@ namespace DeviceCommand.Devices
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询电源的实时实际输出电流 (MEASure 相关寄存器)
|
||||
/// 查询电源的实时实际输出电流 (MEASure:CURRent?)
|
||||
/// </summary>
|
||||
/// <param name="ct">取消令牌</param>
|
||||
/// <returns>实时电流值 (单位: A,保留两位小数)</returns>
|
||||
@@ -264,47 +146,30 @@ namespace DeviceCommand.Devices
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取状态字,判断是否处于恒压 (CV) 模式或恒流 (CC) 模式
|
||||
/// 状态字节位详解:位 2,3 = 1(CV), 2(CC)
|
||||
/// 判断输出处于恒压 (CV) 还是恒流 (CC) 模式。
|
||||
/// IT67xx SCPI 无直接 CV/CC 查询命令,此处以"实测电流是否顶到限流设定值"启发式判断:
|
||||
/// 输出关闭返回 OFF;实测电流达到限流值(≥98%)判为 CC,否则 CV。
|
||||
/// </summary>
|
||||
public virtual async Task<string> 查询输出模式(CancellationToken ct = default)
|
||||
{
|
||||
var (_, _, status) = await 查询实时测量值(ct);
|
||||
int mode = (status >> 2) & 0x03;
|
||||
return mode switch
|
||||
{
|
||||
1 => "CV",
|
||||
2 => "CC",
|
||||
_ => "UNKNOWN"
|
||||
};
|
||||
var (current, _, status) = await 查询实时测量值(ct);
|
||||
if ((status & 0x01) == 0) return "OFF";
|
||||
if (_setCurrentLimit > 0 && current >= _setCurrentLimit * 0.98) return "CC";
|
||||
return "CV";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 5. 读取设备信息(命令字 31H)
|
||||
#region 4. 设备信息与错误
|
||||
|
||||
/// <summary>
|
||||
/// 读取电源的序列号、型号及软件版本
|
||||
/// 读取电源的标识信息 (*IDN?:制造商, 型号, 序列号, 固件版本)
|
||||
/// </summary>
|
||||
public virtual async Task<string> 查询设备信息(CancellationToken ct = default)
|
||||
{
|
||||
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. 扩展系统命令(兼容接口)
|
||||
=> await QueryAsync("*IDN?", ct);
|
||||
|
||||
/// <summary>
|
||||
/// 基类自带的超时和清空缓冲区已经处理了大部分错误。
|
||||
/// 如需在面板显示具体错误,需通过状态位自行判断或查看面板 ERROR 灯。
|
||||
/// IT67xx 未提供标准错误队列查询,返回面板提示说明
|
||||
/// </summary>
|
||||
public virtual async Task<string> 查询错误信息(CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -236,7 +236,8 @@ namespace DeviceCommand.Devices
|
||||
/// <param name="ct">取消令牌</param>
|
||||
public async Task 设置CV正向电流(double 电流, CancellationToken ct = default)
|
||||
{
|
||||
await SendAsync($"CURRent:LIMit:POSitive {电流}{ScpiDelimiter}", ct);
|
||||
await SendAsync($"VOLTage:ILIMit {电流}{ScpiDelimiter}", ct);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -247,7 +248,7 @@ namespace DeviceCommand.Devices
|
||||
public async Task 设置CV反向电流(double 电流, CancellationToken ct = default)
|
||||
{
|
||||
// 载模式限制需要负数,若传入正数建议在外部转换为负值
|
||||
await SendAsync($"CURRent:LIMit:NEGative {电流}{ScpiDelimiter}", ct);
|
||||
await SendAsync($"VOLTage:ILIMit:NEGative {电流}{ScpiDelimiter}", ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -186,8 +186,9 @@ namespace MonitorModule.ViewModels
|
||||
if (!string.IsNullOrEmpty(TestStatus))
|
||||
_canSignalBroadcaster?.UnregisterScope(TestStatus);
|
||||
|
||||
// 3. 释放容器作用域
|
||||
_scope?.Dispose();
|
||||
// 注意:_scope 是从 GlobalInfo.ScopeDic 借用的工位作用域,拥有者是 AutomatedTestingViewModel,
|
||||
// 这里不能 Dispose,否则关闭监控页就会销毁整个工位(DeviceManager/SystemConfig 全部失效,
|
||||
// 并让 CAN 卡的作用域引用计数提前归零)。只清理本页面自己的资源即可。
|
||||
Channels.Clear();
|
||||
AvailableMethods.Clear();
|
||||
}
|
||||
|
||||
@@ -173,7 +173,8 @@ namespace MonitorModule.ViewModels
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_scope?.Dispose();
|
||||
// _scope 来自 GlobalInfo.ScopeDic,拥有者是 AutomatedTestingViewModel,
|
||||
// 借用方不能 Dispose,否则关闭查询页会销毁整个工位并让 CAN 引用计数提前归零
|
||||
}
|
||||
|
||||
#region 筛选条件加载
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@
|
||||
xmlns:prism="http://prismlibrary.com/"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
xmlns:converters="clr-namespace:UIShare.Converters;assembly=UIShare"
|
||||
xmlns:oxy="http://oxyplot.org/wpf"
|
||||
prism:ViewModelLocator.AutoWireViewModel="True"
|
||||
d:DesignHeight="1080"
|
||||
d:DesignWidth="1920">
|
||||
@@ -19,8 +18,8 @@
|
||||
<Border Background="#F5F7FA">
|
||||
<Grid Margin="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="440" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="360" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ============ 左侧:Chroma 命令面板 ============ -->
|
||||
@@ -97,6 +96,9 @@
|
||||
Style="{StaticResource WarnBtn}" />
|
||||
<Button Content="复位" Command="{Binding ResetCommand}"
|
||||
Style="{StaticResource CmdBtn}" />
|
||||
<Button Content="退出远程" Command="{Binding ExitRemoteCommand}"
|
||||
Style="{StaticResource CmdBtn}"
|
||||
ToolTip="SYST:LOC:退出远程状态、恢复前面板操作(手册注明僅 RS-232 控制下有效)" />
|
||||
<Button Content="查询 IDN" Command="{Binding QueryIdnCommand}"
|
||||
Style="{StaticResource CmdBtn}" />
|
||||
</StackPanel>
|
||||
@@ -161,7 +163,8 @@
|
||||
<TextBlock Text="波形" Style="{StaticResource ParamLabel}" />
|
||||
<ComboBox Width="130" Height="32" FontSize="12" Margin="4,0"
|
||||
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}"
|
||||
Style="{StaticResource CmdBtn}" />
|
||||
</StackPanel>
|
||||
@@ -183,11 +186,15 @@
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4">
|
||||
<TextBlock Text="名称" Style="{StaticResource ParamLabel}" />
|
||||
<TextBox Style="{StaticResource NumInput}" Width="150"
|
||||
ToolTip="保存时作为文件名(写入 D:\acp\源测试);选中已有测试项会自动填充,可改名另存"
|
||||
ToolTip="另存为对话框的默认文件名(写入 D:\acp\源测试);载入文件后自动回显当前文件名,保存不改动文件名"
|
||||
Text="{Binding PresetName, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button Content="保存" Command="{Binding SavePresetCommand}"
|
||||
Style="{StaticResource CmdBtn}"
|
||||
ToolTip="覆盖保存当前打开的测试项文件(不改变文件名);未打开文件时按“另存为”新建"
|
||||
Background="#388E3C" Foreground="White" />
|
||||
<Button Content="另存为" Command="{Binding SavePresetAsCommand}"
|
||||
Style="{StaticResource CmdBtn}"
|
||||
ToolTip="弹出文件对话框选择 .json 位置另存;存入 D:\acp\源测试 才会出现在下拉列表" />
|
||||
<Button Content="删除" Command="{Binding DeletePresetCommand}"
|
||||
Style="{StaticResource WarnBtn}"
|
||||
ToolTip="删除下拉框当前选中的测试项文件(带二次确认)" />
|
||||
@@ -222,6 +229,11 @@
|
||||
<ComboBox Width="80" Height="32" FontSize="12" Margin="4,0"
|
||||
ItemsSource="{Binding ListTimeBaseOptions}"
|
||||
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>
|
||||
|
||||
<Separator Margin="0,6" />
|
||||
@@ -230,10 +242,6 @@
|
||||
<ComboBox Width="100" Height="32" FontSize="12" Margin="4,0"
|
||||
ItemsSource="{Binding ListPhaseOptions}"
|
||||
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>
|
||||
<Button Content="复制当前相到三相(B/C 相位角自动 ±120°)"
|
||||
Command="{Binding CopyPhaseToThreeCommand}"
|
||||
@@ -309,7 +317,7 @@
|
||||
<TextBlock Grid.Row="8" Grid.Column="0" Text="波形顺序"
|
||||
Style="{StaticResource ParamLabel}" />
|
||||
<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}" />
|
||||
</Grid>
|
||||
|
||||
@@ -324,45 +332,107 @@
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 数据采集 -->
|
||||
<GroupBox Header="数据采集" Margin="0,0,0,8"
|
||||
<!-- SYNTHESIS 自定义谐波波形输出 -->
|
||||
<GroupBox Header="SYNTHESIS 波形输出(自定义谐波)" Margin="0,0,0,8"
|
||||
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
|
||||
<StackPanel Margin="4">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4">
|
||||
<Button Content="开始采集" Command="{Binding StartSamplingCommand}"
|
||||
Style="{StaticResource CmdBtn}"
|
||||
Background="#388E3C" Foreground="White" />
|
||||
<Button Content="停止采集" Command="{Binding StopSamplingCommand}"
|
||||
<TextBlock Text="波形文件" Style="{StaticResource ParamLabel}" />
|
||||
<ComboBox Width="210" Height="32" FontSize="12" Margin="4,0"
|
||||
ItemsSource="{Binding SynthPresetOptions}"
|
||||
SelectedIndex="{Binding SelectedSynthPresetIndex}" />
|
||||
<Button Content="载入" Command="{Binding LoadSynthPresetCommand}"
|
||||
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 Orientation="Horizontal" Margin="0,4">
|
||||
<CheckBox Content="采集频率" IsChecked="{Binding IsFrequencySamplingEnabled}"
|
||||
VerticalAlignment="Center" Margin="4,0,10,0"
|
||||
ToolTip="频率一般无需持续采集;勾选后采样循环才读取频率并绘制到右侧频率曲线" />
|
||||
<TextBlock Text="频率" Style="{StaticResource ParamLabel}" />
|
||||
<TextBox Style="{StaticResource MeasureBox}"
|
||||
Text="{Binding FrequencyNow, Mode=OneWay, StringFormat={}{0:F2}}" />
|
||||
<TextBlock Text="Hz" VerticalAlignment="Center" Margin="2,0,12,0" />
|
||||
<TextBlock Text="功率" Style="{StaticResource ParamLabel}" />
|
||||
<TextBox Style="{StaticResource MeasureBox}"
|
||||
Text="{Binding PowerNow, Mode=OneWay, StringFormat={}{0:F2}}" />
|
||||
<TextBlock Text="W" VerticalAlignment="Center" Margin="2,0,0,0" />
|
||||
<TextBlock Text="名称" Style="{StaticResource ParamLabel}" />
|
||||
<TextBox Style="{StaticResource NumInput}" Width="150"
|
||||
ToolTip="另存为对话框的默认文件名(写入 D:\acp\源测试\合成波形);载入文件后自动回显当前文件名,保存不改动文件名"
|
||||
Text="{Binding SynthPresetName, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button Content="保存" Command="{Binding SaveSynthPresetCommand}"
|
||||
Style="{StaticResource CmdBtn}"
|
||||
ToolTip="覆盖保存当前打开的波形文件(不改变文件名);未打开文件时按“另存为”新建"
|
||||
Background="#388E3C" Foreground="White" />
|
||||
<Button Content="另存为" Command="{Binding SaveSynthPresetAsCommand}"
|
||||
Style="{StaticResource CmdBtn}"
|
||||
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>
|
||||
</GroupBox>
|
||||
@@ -380,69 +450,234 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ 右侧:三相电压 + 输出频率(横轴为采集经过时间 s) ============ -->
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<!-- ============ 右侧:示波器抓波面板 ============ -->
|
||||
<ScrollViewer Grid.Column="1"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel>
|
||||
|
||||
<!-- 三相电压 -->
|
||||
<Border Grid.Row="0" Background="White" BorderBrush="#DDD" BorderThickness="1" Margin="0,0,0,4">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel Orientation="Horizontal" Margin="10,6">
|
||||
<TextBlock Text="三相电压 (V)" FontWeight="Bold" FontSize="13"
|
||||
VerticalAlignment="Center" Margin="0,0,16,0" />
|
||||
<TextBlock Text="A" FontSize="12" Foreground="#E53935" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding VoltageA, StringFormat={}{0:F1}}" FontSize="12"
|
||||
FontWeight="Bold" VerticalAlignment="Center" Margin="3,0,14,0" />
|
||||
<TextBlock Text="B" FontSize="12" Foreground="#43A047" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding VoltageB, StringFormat={}{0:F1}}" FontSize="12"
|
||||
FontWeight="Bold" VerticalAlignment="Center" Margin="3,0,14,0" />
|
||||
<TextBlock Text="C" FontSize="12" Foreground="#1E88E5" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding VoltageC, StringFormat={}{0:F1}}" FontSize="12"
|
||||
FontWeight="Bold" VerticalAlignment="Center" Margin="3,0,0,0" />
|
||||
</StackPanel>
|
||||
<oxy:PlotView Grid.Row="1" Model="{Binding VoltagePlot}"
|
||||
Background="Transparent" Margin="4,0,4,4" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<GroupBox Header="示波器屏幕抓图(TektronixMSO)"
|
||||
materialDesign:ColorZoneAssist.Mode="PrimaryLight">
|
||||
<StackPanel Margin="4">
|
||||
<!-- 示波器连接状态 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,2,0,4">
|
||||
<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 IsScopeConnected}" Value="True">
|
||||
<Setter Property="Background" Value="#4CAF50" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding ScopeName}"
|
||||
FontSize="12" Foreground="#616161"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 输出频率(受左侧“采集频率”开关控制,一般不采集) -->
|
||||
<Border Grid.Row="1" Background="White" BorderBrush="#DDD" BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel Orientation="Horizontal" Margin="10,6">
|
||||
<TextBlock Text="输出频率 (Hz)" FontWeight="Bold" FontSize="13"
|
||||
VerticalAlignment="Center" Margin="0,0,16,0" />
|
||||
<TextBlock Text="{Binding FrequencyNow, StringFormat={}{0:F2}}" FontSize="12"
|
||||
FontWeight="Bold" Foreground="#8E24AA" VerticalAlignment="Center" Margin="3,0,14,0" />
|
||||
<TextBlock FontSize="11" Foreground="#4CAF50" VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Text" Value="频率采集中" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsFrequencySamplingEnabled}" Value="False">
|
||||
<Setter Property="Text" Value="频率采集未开启(在左侧“数据采集”勾选后才会绘制)" />
|
||||
<Setter Property="Foreground" Value="#9E9E9E" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<!-- 波形图片存储位置 -->
|
||||
<TextBlock Text="波形图片存储位置"
|
||||
FontSize="11" Foreground="#616161" Margin="4,0,4,2" />
|
||||
<Grid Margin="0,0,0,2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Style="{StaticResource NumInput}" Width="Auto"
|
||||
ToolTip="抓取的屏幕图片 PNG 保存目录;也可直接在框内修改,抓图/浏览时自动记住"
|
||||
Text="{Binding ScopeImageDir, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button Grid.Column="1" Content="浏览…" Command="{Binding BrowseScopeImageDirCommand}"
|
||||
Style="{StaticResource CmdBtn}" Margin="4,0,0,0"
|
||||
ToolTip="弹出文件夹选择对话框选择存储位置" />
|
||||
</Grid>
|
||||
<Button Content="抓取示波器屏幕图片" Command="{Binding CaptureScopeImageCommand}"
|
||||
Style="{StaticResource CmdBtn}"
|
||||
Background="#388E3C" Foreground="White"
|
||||
HorizontalAlignment="Left" Margin="0,4"
|
||||
ToolTip="抓取示波器当前屏幕保存为 PNG(SAVe:IMAGe),仅保存文件;查看波形图请点下方 LIST / SYNTHESIS" />
|
||||
|
||||
<!-- 波形截图:按当前载入配置文件的文件名(去 .json 后缀)查找同名 .png -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<TextBlock Text="波形图" Style="{StaticResource ParamLabel}" />
|
||||
<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="暂无波形图片
点 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>
|
||||
<oxy:PlotView Grid.Row="1" Model="{Binding FrequencyPlot}"
|
||||
Background="Transparent" Margin="4,0,4,4" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 采集控制:运行/停止采集,定格画面后抓图最稳定 -->
|
||||
<GroupBox Header="采集控制" Margin="0,8,0,0"
|
||||
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>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
||||
+633
-86
@@ -22,8 +22,28 @@ namespace TSMasterCAN
|
||||
public static event Action DisConnectEvent;
|
||||
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>
|
||||
/// 初始化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>
|
||||
/// <param name="ProjectName"></param>
|
||||
/// <param name="filePath"></param>
|
||||
@@ -31,46 +51,264 @@ namespace TSMasterCAN
|
||||
[Browsable(false)]
|
||||
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>
|
||||
/// 释放CAN驱动
|
||||
/// 释放CAN驱动。
|
||||
/// <para>释放后清除初始化缓存与回调注册状态,允许下一次 Init 重新初始化。</para>
|
||||
/// </summary>
|
||||
[Browsable(false)]
|
||||
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 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)]
|
||||
public static int RegisterListener(TCANFDQueueEvent_Win32 listenEvent)
|
||||
{
|
||||
listener = listenEvent;
|
||||
var re = TsMasterApi.tsapp_register_event_canfd(ref obj, listener);
|
||||
Debug.Assert(re == 0);
|
||||
return re;
|
||||
if (listenEvent == null) return -1;
|
||||
try
|
||||
{
|
||||
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)]
|
||||
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>
|
||||
@@ -80,27 +318,44 @@ namespace TSMasterCAN
|
||||
/// <returns>返回代码,0 表示成功,非 0 表示失败(可用 <see cref="GetErrorDescription"/> 查询错误说明)</returns>
|
||||
public static int Connect()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_nativeSendLock)
|
||||
{
|
||||
// 多工位共用同一张 CAN 卡:任一工位已连接即复用,避免重复 tsapp_connect
|
||||
if (ConnectFlag) return 0;
|
||||
|
||||
var re = TsMasterApi.tsapp_connect();
|
||||
if (re == 0)
|
||||
{
|
||||
re = TsMasterApi.tscom_can_rbs_start();
|
||||
if (re == 0)
|
||||
{
|
||||
ConnectFlag = true;
|
||||
Task.Run(() => ConnectEvent?.Invoke());
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.WriteLine($"CAN_RBS启动失败,错误代码:{re}");
|
||||
var re = TsMasterApi.tsapp_connect();
|
||||
if (re == 0)
|
||||
{
|
||||
re = TsMasterApi.tscom_can_rbs_start();
|
||||
if (re == 0)
|
||||
{
|
||||
ConnectFlag = true;
|
||||
Task.Run(() =>
|
||||
{
|
||||
try { ConnectEvent?.Invoke(); }
|
||||
catch (Exception ex) { Debug.WriteLine($"CAN.ConnectEvent 订阅者异常:{ex.Message}"); }
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.WriteLine($"CAN_RBS启动失败,错误代码:{re}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.WriteLine($"CAN连接失败,错误代码:{re}");
|
||||
return re;
|
||||
}
|
||||
return re;
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"CAN连接失败,错误代码:{re}");
|
||||
return re;
|
||||
Debug.WriteLine($"CAN.Connect 异常:{ex.Message}");
|
||||
return -1;
|
||||
}
|
||||
return re;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -109,20 +364,36 @@ namespace TSMasterCAN
|
||||
/// <returns>返回代码,0 表示成功,非 0 表示失败(可用 <see cref="GetErrorDescription"/> 查询错误说明)</returns>
|
||||
public static int DisConnect()
|
||||
{
|
||||
var re = TsMasterApi.tsapp_disconnect();
|
||||
//Debug.Assert(re == 0);
|
||||
if (re == 0)
|
||||
try
|
||||
{
|
||||
ConnectFlag = false;
|
||||
Task.Run(() => DisConnectEvent?.Invoke());
|
||||
lock (_nativeSendLock)
|
||||
{
|
||||
// 已断开则直接返回:多工位场景下避免后续工位重复断开总线
|
||||
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>
|
||||
@@ -137,16 +408,137 @@ namespace TSMasterCAN
|
||||
{
|
||||
databaseID = 0;
|
||||
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)
|
||||
{
|
||||
DBCParse.parse(databaseID, item);
|
||||
DBCParse.rbs_parse(databaseID, item);
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 卸载DBC
|
||||
/// </summary>
|
||||
@@ -155,13 +547,32 @@ namespace TSMasterCAN
|
||||
[Browsable(false)]
|
||||
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>
|
||||
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>
|
||||
@@ -181,7 +600,15 @@ namespace TSMasterCAN
|
||||
/// <returns>返回代码,0 表示成功,非 0 表示失败</returns>
|
||||
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>
|
||||
@@ -205,8 +632,16 @@ namespace TSMasterCAN
|
||||
public static double GetSignalValue(byte channel, string AMsgName, string ASgnName)
|
||||
{
|
||||
double value = double.NaN;
|
||||
var find = DBCParse.MsgDatabase[channel].First(s => s.msg_name == AMsgName);
|
||||
var re = TsMasterApi.tsdb_get_signal_value_canfd(ref find.ACANFD, AMsgName, ASgnName, ref value);
|
||||
var find = DBCParse.MsgDatabase[channel].FirstOrDefault(s => s.msg_name == AMsgName);
|
||||
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;
|
||||
}
|
||||
@@ -222,8 +657,17 @@ namespace TSMasterCAN
|
||||
[Browsable(false)]
|
||||
public static int GetSignalValue(byte channel, string AMsgName, string ASgnName, ref double value)
|
||||
{
|
||||
var find = DBCParse.MsgDatabase[channel].First(s => s.msg_name == AMsgName);
|
||||
return TsMasterApi.tsdb_get_signal_value_canfd(ref find.ACANFD, AMsgName, ASgnName, ref value);
|
||||
var find = DBCParse.MsgDatabase[channel].FirstOrDefault(s => s.msg_name == AMsgName);
|
||||
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>
|
||||
@@ -254,21 +698,41 @@ namespace TSMasterCAN
|
||||
/// <exception cref="Exception">信号编码写入报文失败时抛出</exception>
|
||||
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;
|
||||
var re = TsMasterApi.tsdb_set_signal_value_canfd(ref find.ACANFD, AMsgName, ASgnName, AValue);
|
||||
Debug.Assert(re == 0);
|
||||
if (re != 0) throw new Exception($"设置报文失败!返回代码{re}");
|
||||
if (re != 0) return re;
|
||||
if (re != 0)
|
||||
{
|
||||
// 93 = message not found in database:原生库在该通道上找不到这个报文名。
|
||||
// 常见成因:驱动被释放 / DBC 被卸载后托管缓存没同步,或 DBC 实际加载在别的通道。
|
||||
throw new Exception($"设置报文失败!返回代码{re}({GetErrorDescription(re)})");
|
||||
}
|
||||
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;
|
||||
@@ -284,15 +748,27 @@ namespace TSMasterCAN
|
||||
/// <returns>返回代码,0 表示成功,非 0 表示失败</returns>
|
||||
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;
|
||||
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)]
|
||||
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>
|
||||
@@ -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 = 只发送一次;>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>
|
||||
/// 构造并发送自定义 CAN/CANFD 报文,可选择单次发送或周期发送,并可同步更新 DBC 数据库中同 ID 报文的数据。
|
||||
/// </summary>
|
||||
@@ -359,16 +863,27 @@ namespace TSMasterCAN
|
||||
int length = Math.Min(ADataArray.Length, 64);
|
||||
Array.Copy(ADataArray, 0, send.FData, 0, length);
|
||||
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);
|
||||
if (find != null)
|
||||
@@ -388,18 +903,42 @@ namespace TSMasterCAN
|
||||
/// <returns>返回代码,0 表示成功,非 0 表示失败</returns>
|
||||
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;
|
||||
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>
|
||||
/// 清除所有循环发送的报文,停止全部周期发送任务。
|
||||
/// <para>注意:TSMaster 的循环发送表是全局的,此调用会同时清掉其他工位的循环发送。</para>
|
||||
/// </summary>
|
||||
/// <returns>返回代码,0 表示成功,非 0 表示失败</returns>
|
||||
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>
|
||||
@@ -409,15 +948,23 @@ namespace TSMasterCAN
|
||||
/// <returns>错误描述文本,未知错误代码时返回提示信息</returns>
|
||||
public static string GetErrorDescription(int errorCode)
|
||||
{
|
||||
IntPtr ADesc = IntPtr.Zero;
|
||||
TsMasterApi.tsapp_get_error_description(errorCode, ref ADesc);
|
||||
try
|
||||
{
|
||||
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*)
|
||||
string? description = Marshal.PtrToStringAnsi(ADesc);
|
||||
// 假设返回的是 ANSI 字符串(如 C 的 char*)
|
||||
string? description = Marshal.PtrToStringAnsi(ADesc);
|
||||
|
||||
return description ?? $"未知错误代码: {errorCode}";
|
||||
return description ?? $"未知错误代码: {errorCode}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"CAN.GetErrorDescription 异常:{ex.Message}");
|
||||
return $"未知错误代码: {errorCode}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -125,6 +125,19 @@ namespace TSMasterCAN
|
||||
public static List<List<_Msg_>> MsgDatabase { get; set; } =
|
||||
[.. Enumerable.Range(0, 12).Select(_ => new List<_Msg_>())];
|
||||
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)
|
||||
{
|
||||
nint tmpInt = new nint();
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Interop.TSMasterAPI">
|
||||
<HintPath>..\CanDriver\bin\Debug\net8.0\Interop.TSMasterAPI.dll</HintPath>
|
||||
<HintPath>Interop.TSMasterAPI.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TSMasterCAN;
|
||||
using TSMaster;
|
||||
using Logger;
|
||||
namespace UIShare.GlobalVariable
|
||||
{
|
||||
public class CANMonitoringService
|
||||
@@ -23,79 +24,106 @@ namespace UIShare.GlobalVariable
|
||||
}
|
||||
public int Init(CancellationToken ct = default)
|
||||
{
|
||||
int re = 0;
|
||||
if (IsInitialized == false)
|
||||
// 多工位共用同一个 TSMaster 实例:首次工位已完成 initialize_lib_tsmaster,
|
||||
// 后续工位的 CAN.Init 会直接复用首次结果(返回 0),因此这里不能再用
|
||||
// “初始化未成功就跳过”的判断,否则第二个工位永远注册不到报文回调。
|
||||
var initRe = CAN.Init(_systemConfig.TSMasterName);
|
||||
if (initRe != 0)
|
||||
{
|
||||
re = CAN.Init(_systemConfig.TSMasterName);
|
||||
}
|
||||
if (IsInitialized == false)
|
||||
{
|
||||
re = CAN.RegisterListener(MonitorEvent);
|
||||
if (re == 0) IsInitialized = true;
|
||||
LoggerHelper.Warn($"CAN 驱动初始化返回 {initRe}({CAN.GetErrorDescription(initRe)}),继续注册报文回调。");
|
||||
}
|
||||
|
||||
// RegisterListener 幂等:重复调用只补充订阅者;驱动被 Release 后再 Init 也能重新挂回原生回调
|
||||
var re = CAN.RegisterListener(MonitorEvent);
|
||||
if (re == 0) IsInitialized = true;
|
||||
return re;
|
||||
}
|
||||
public ConcurrentDictionary<string, TLIBCANFD> RealTimeMessages { get; set; } = new();
|
||||
public ConcurrentDictionary<string, double> RealTimeSignals { get; set; } = new();
|
||||
/// <summary>
|
||||
/// TSMaster 原生库收报文回调(原生线程直接调用)。
|
||||
/// 回调内抛出的异常一旦跨出原生边界会直接导致进程闪退,
|
||||
/// 因此整个方法体必须包裹异常隔离,且不做任何可能阻塞的调用。
|
||||
/// </summary>
|
||||
public void MonitorEvent(ref int AObj, ref TLIBCANFD AData)
|
||||
{
|
||||
if (IsStopped) return;
|
||||
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))
|
||||
try
|
||||
{
|
||||
oldbytes = v.FData;
|
||||
}
|
||||
RealTimeMessages[MessageName] = cpAData;
|
||||
if (IsStopped) return;
|
||||
|
||||
//记录至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, 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;
|
||||
}
|
||||
// 通道越界防护:多通道/异常帧的 FIdxChn 可能超出 DBC 数据库(12 通道)范围
|
||||
if (AData.FIdxChn >= DBCParse.MsgDatabase.Count) return;
|
||||
if (AData.FData == null) return;
|
||||
|
||||
if (oldbytes is not null) ArrayPool<byte>.Shared.Return(oldbytes);
|
||||
byte[] oldbytes = null;
|
||||
|
||||
var id = AData.FIdentifier;
|
||||
var ch = AData.FIdxChn;
|
||||
|
||||
var canSignalList = _systemConfig.ConfigurationList.Where(s => s.MessageID == id && s.Channel == ch).ToArray();
|
||||
|
||||
if (canSignalList.Length > 0)
|
||||
{
|
||||
var find = DBCParse.MsgDatabase[ch].First(s => s.msg_id == id);
|
||||
foreach (var item in canSignalList)
|
||||
//记录至实时报文数据库
|
||||
var cpAData = AData;
|
||||
cpAData.FData = ArrayPool<byte>.Shared.Rent(64);
|
||||
Array.Copy(AData.FData, 0, cpAData.FData, 0, Math.Min(AData.FData.Length, cpAData.FData.Length));
|
||||
var MessageName = $"{AData.FIdxChn}/{AData.FIdentifier}";
|
||||
if (RealTimeMessages.TryGetValue(MessageName, out var v))
|
||||
{
|
||||
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;
|
||||
oldbytes = v.FData;
|
||||
}
|
||||
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>
|
||||
/// 停止监测:从广播器注销并清理实时数据。
|
||||
/// 停止监测:注销原生报文回调、从广播器注销并清理实时数据。
|
||||
/// <para>必须先反注册回调再释放实例,否则原生线程仍会回调到已停止的工位。</para>
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
IsStopped = true;
|
||||
if (IsRegistered)
|
||||
{
|
||||
CAN.UnRegisterListener(MonitorEvent);
|
||||
CANSignalBroadcaster.UnregisterService(this);
|
||||
IsRegistered = false;
|
||||
}
|
||||
|
||||
@@ -150,20 +150,28 @@ namespace UIShare.GlobalVariable
|
||||
string methodName = BuildMethodName((uint)cfg.MessageID, signalName);
|
||||
|
||||
// 广播信号值到对应作用域
|
||||
_eventAggregator.GetEvent<HardwareDataReportedEvent>().Publish(new HardwareReportArgs
|
||||
// 逐信号隔离:单个信号广播/报警失败不应中断整个广播循环
|
||||
try
|
||||
{
|
||||
Scope = scopeName,
|
||||
HardwareFingerprint = fingerprint,
|
||||
MethodName = methodName,
|
||||
Value = kvp.Value,
|
||||
Time = DateTime.Now
|
||||
});
|
||||
_eventAggregator.GetEvent<HardwareDataReportedEvent>().Publish(new HardwareReportArgs
|
||||
{
|
||||
Scope = scopeName,
|
||||
HardwareFingerprint = fingerprint,
|
||||
MethodName = methodName,
|
||||
Value = kvp.Value,
|
||||
Time = DateTime.Now
|
||||
});
|
||||
|
||||
// 报警检查(使用该作用域自己的配置)
|
||||
string alarmStatus = ValueLimitAlarmHelper.CheckAlarm(fingerprint, methodName, kvp.Value, scopeConfig);
|
||||
if (!string.IsNullOrEmpty(alarmStatus) && alarmStatus != "未报警")
|
||||
// 报警检查(使用该作用域自己的配置)
|
||||
string alarmStatus = ValueLimitAlarmHelper.CheckAlarm(fingerprint, methodName, kvp.Value, scopeConfig);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,10 +48,39 @@ namespace UIShare.GlobalVariable
|
||||
_CANMonitoringService=new CANMonitoringService(_systemConfig);
|
||||
// 用 SystemConfig.Title 作为作用域唯一标识,无需反查 ConfigDic
|
||||
_scopeName = _systemConfig.Title;
|
||||
AcquireCanScope();
|
||||
InitDevices();
|
||||
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()
|
||||
{
|
||||
var re1 = _CANMonitoringService.Init();
|
||||
@@ -112,6 +141,13 @@ namespace UIShare.GlobalVariable
|
||||
{
|
||||
if (config == null || !config.IsEnabled) continue;
|
||||
|
||||
// CAN 卡由 ConnectCanAsync 独立管理(静态 TSMaster 驱动 + 作用域引用计数),
|
||||
// 不参与 HardwarePool 的设备实例池,这里直接跳过避免误报"未知设备类型"
|
||||
if (string.Equals(config.ConnectionType, "CAN", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.DeviceType) ||
|
||||
!_deviceTypeMap.TryGetValue(config.DeviceType, out var deviceType))
|
||||
{
|
||||
@@ -314,11 +350,18 @@ namespace UIShare.GlobalVariable
|
||||
// 先停止健康监控,避免重连定时器与关闭操作冲突
|
||||
StopHealthMonitor();
|
||||
|
||||
// CAN 卡按作用域引用计数释放:仅最后一个工位关闭时才停循环报文、断总线并释放驱动
|
||||
ReleaseCanScope();
|
||||
|
||||
List<Task> tasks = new List<Task>();
|
||||
|
||||
lock (_lockObj)
|
||||
{
|
||||
if (DeviceMap.Count == 0 ) return;
|
||||
if (DeviceMap.Count == 0)
|
||||
{
|
||||
LoggerHelper.Info("所有设备已执行关闭操作。");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kvp in DeviceMap)
|
||||
{
|
||||
@@ -331,7 +374,6 @@ namespace UIShare.GlobalVariable
|
||||
}
|
||||
}
|
||||
|
||||
CAN.DisConnect();
|
||||
await Task.WhenAll(tasks);
|
||||
LoggerHelper.Info("所有设备已执行关闭操作。");
|
||||
}
|
||||
@@ -453,9 +495,16 @@ namespace UIShare.GlobalVariable
|
||||
|
||||
try
|
||||
{
|
||||
// 重新连接时恢复本工位对 CAN 卡的引用(之前可能被主动关闭过)
|
||||
AcquireCanScope();
|
||||
// 驱动若曾被最后一个工位释放,这里重新初始化并把报文回调挂回原生库
|
||||
InitCAN();
|
||||
|
||||
bool ok = await Task.Run(() =>
|
||||
{
|
||||
// 自动加载 DBC 文件
|
||||
// 一拖多共享 CAN 卡:同一通道 + 同一文件只加载一次,
|
||||
// 其他工位已经加载过时直接跳过,避免重复下发并重复追加 DBCParse 报文库
|
||||
if (_systemConfig?.DBCAutoLoadList != null)
|
||||
{
|
||||
foreach (var item in _systemConfig.DBCAutoLoadList)
|
||||
@@ -474,11 +523,22 @@ namespace UIShare.GlobalVariable
|
||||
LoggerHelper.Warn($"CAN 通道 {item.DBCChannel} 自动加载 DBC 失败:文件不存在 [{item.DBCFilePath}]");
|
||||
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)
|
||||
{
|
||||
LoggerHelper.Info($"CAN 通道 {item.DBCChannel} 已自动加载 DBC:{item.DBCFilePath}");
|
||||
// 发布 DBC 加载完成事件,通知监控系统刷新信号列表
|
||||
if (alreadyLoaded)
|
||||
{
|
||||
LoggerHelper.Info($"CAN 通道 {item.DBCChannel} DBC 已由其他工位加载,跳过重复加载:{item.DBCFilePath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
LoggerHelper.Info($"CAN 通道 {item.DBCChannel} 已自动加载 DBC:{item.DBCFilePath}");
|
||||
}
|
||||
|
||||
// 即使跳过加载,也要通知本工位监控系统刷新信号列表
|
||||
_eventAggregator.GetEvent<DBCLoadedEvent>().Publish(new DBCLoadedArgs
|
||||
{
|
||||
Channel = (uint)item.DBCChannel,
|
||||
@@ -519,11 +579,22 @@ namespace UIShare.GlobalVariable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭 CAN 卡
|
||||
/// 关闭 CAN 卡:按作用域引用计数释放。
|
||||
/// <para>
|
||||
/// CAN 卡为一拖多共享硬件,这里只注销本工位的引用;
|
||||
/// 仅当最后一个工位释放时,<see cref="CAN.ReleaseScope"/> 才停止循环报文、断开总线并释放驱动。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void CloseCan()
|
||||
{
|
||||
CAN.DisConnect();
|
||||
try
|
||||
{
|
||||
ReleaseCanScope();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoggerHelper.Error($"关闭 CAN 卡异常(已隔离):{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, Type> BuildDeviceTypeMap()
|
||||
@@ -612,6 +683,9 @@ namespace UIShare.GlobalVariable
|
||||
// 停止 CAN 信号监测服务
|
||||
_CANMonitoringService?.Stop();
|
||||
|
||||
// CAN 卡按作用域引用计数释放:最后一个工位销毁时才停循环报文、断总线并释放驱动
|
||||
ReleaseCanScope();
|
||||
|
||||
if (string.IsNullOrEmpty(_scopeName)) return;
|
||||
|
||||
// 遍历当前作用域用到的所有指纹,逐一移除本作用域的引用
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user