示波器修改:visa连接方式+截图保存

This commit is contained in:
“hsc”
2026-09-23 09:16:14 +08:00
parent 3275283263
commit 81c6386e62
15 changed files with 910 additions and 88 deletions
+31
View File
@@ -0,0 +1,31 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace DeviceCommand.Base
{
/// <summary>
/// VISA SCPI 通信接口:与 <see cref="ITcp"/> 暴露一致的异步收发能力,
/// 使 SCPI 设备类可在 Tcp 与 VISA 传输之间平滑切换。
/// </summary>
public interface IVisa : IBaseInterface, IDisposable
{
/// <summary>发送原始二进制数据。</summary>
Task SendAsync(byte[] buffer, CancellationToken ct = default);
/// <summary>发送字符串命令(SCPI 命令自带结束符)。</summary>
Task SendAsync(string str, CancellationToken ct = default);
/// <summary>读取指定长度的原始字节。</summary>
Task<byte[]> ReadAsync(int length, CancellationToken ct = default);
/// <summary>读取一行字符串,直到遇到结束符。</summary>
Task<string> ReadAsync(string delimiter = "\n", CancellationToken ct = default);
/// <summary>发送命令并读取一行响应。</summary>
Task<string> WriteReadAsync(string command, string delimiter = "\n", CancellationToken ct = default);
/// <summary>发送查询命令并读取 IEEE 488.2 定长二进制块,自动剥离块头(#N&lt;长度&gt;)后只返回纯数据字节(用于截图/波形等大块数据)。</summary>
Task<byte[]> ReadAllBytesAsync(string queryCommand, CancellationToken ct = default);
}
}
+426
View File
@@ -0,0 +1,426 @@
using Model.Models;
using NationalInstruments.Visa;
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DeviceCommand.Base
{
/// <summary>
/// VISA SCPI 通信基类:基于 NI-VISA(<c>NationalInstruments.Visa</c>)实现,
/// 对外暴露与 <see cref="Tcp"/> 完全一致的异步 API(SendAsync / ReadAsync / WriteReadAsync / ReadAllBytesAsync),
/// 使 SCPI 设备类(如 <c>TektronixMSO</c>)能在 Tcp 与 VISA 传输之间平滑切换。
/// <para>VISA 通过统一的资源字符串寻址(TCPIP0::IP::inst0::INSTR、USB0::...、GPIB0::...::INSTR 等),
/// 由 NI-VISA 运行时自动选择对应传输层;本类只面向消息型会话 <see cref="MessageBasedSession"/>。</para>
/// <para>注意:VISA 的读写为同步阻塞调用,本基类统一通过 <see cref="Task.Run(System.Action)"/> 投递到线程池,
/// 以保持与 Tcp 一致的 async/await 契约、避免阻塞 UI 线程。</para>
/// </summary>
public class Visa : IVisa, IDisposable
{
/// <summary>VISA 资源字符串(例如 TCPIP0::192.168.20.125::inst0::INSTR)。</summary>
public string ResourceName { get; set; } = "TCPIP0::127.0.0.1::inst0::INSTR";
/// <summary>打开 VISA 会话的最长等待时间(毫秒)。</summary>
public int OpenTimeout { get; set; } = 3000;
/// <summary>读写 I/O 超时(毫秒),对应 VISA 的 TimeoutMilliseconds。</summary>
public int IOTimeout { get; set; } = 3000;
/// <summary>底层 VISA 消息型会话;为 null 表示未连接。</summary>
private MessageBasedSession? _session;
/// <summary>通信互斥锁:VISA 会话不支持并发读写,所有 I/O 串行化。</summary>
protected readonly SemaphoreSlim _commLock = new(1, 1);
public bool IsConnected => _session != null;
public Visa()
{
}
/// <summary>
/// 通过 <see cref="VisaConfig"/> 一次性配置 VISA 通信参数。
/// </summary>
public Visa(VisaConfig config) : this()
{
if (config == null) return;
ConfigureDevice(config.ResourceName, config.OpenTimeout, config.IOTimeout);
}
public void ConfigureDevice(string resourceName, int openTimeout = 3000, int ioTimeout = 3000)
{
ResourceName = resourceName;
OpenTimeout = openTimeout;
IOTimeout = ioTimeout;
}
public virtual async Task<bool> ConnectAsync(CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
try
{
return await Task.Run(() => ResetConnection(), ct);
}
finally
{
_commLock.Release();
}
}
/// <summary>
/// 核心内部方法:释放旧会话并按资源字符串重建 VISA 会话。
/// </summary>
private bool ResetConnection()
{
DisposeSession();
var session = CreateSession();
// 会话创建失败(资源为空/设备未连接/类型不识别):保持未连接状态返回 false,不抛异常
if (session == null)
return false;
// I/O 超时(读写共用)
session.TimeoutMilliseconds = IOTimeout > 0 ? IOTimeout : 3000;
// SCPI 以 LF(\n) 作为读取终止符
session.TerminationCharacter = (byte)'\n';
session.TerminationCharacterEnabled = true;
// 在写入最后一个字节时发送 END 指示
session.SendEndEnabled = true;
_session = session;
return true;
}
/// <summary>
/// 按 VISA 资源字符串前缀创建对应的具体会话实例;创建失败时返回 <c>null</c>(不抛异常)。
/// <para>NI-VISA .NET 的基类 <see cref="MessageBasedSession"/> 构造函数为 protected,无法直接实例化,
/// 必须使用其 public 构造函数的具体子类(TcpipSession/GpibSession/UsbSession/SerialSession),
/// 它们均派生自 <see cref="MessageBasedSession"/>,故可统一以基类引用持有。</para>
/// <para>资源字符串为空、类型无法识别或底层打开会话失败(如示波器未连接)时一律返回 <c>null</c>,
/// 由 <see cref="ResetConnection"/> 按“未连接”处理,避免设备缺省时抛异常中断上层初始化。</para>
/// </summary>
private MessageBasedSession? CreateSession()
{
string res = ResourceName?.Trim() ?? string.Empty;
// 资源字符串为空:视为该设备未配置/未连接,直接返回 null,不抛异常
if (res.Length == 0)
return null;
string upper = res.ToUpperInvariant();
int openTimeout = OpenTimeout > 0 ? OpenTimeout : 3000;
var mode = Ivi.Visa.AccessModes.None;
try
{
if (upper.StartsWith("TCPIP"))
return new TcpipSession(res, mode, openTimeout);
if (upper.StartsWith("GPIB"))
return new GpibSession(res, mode, openTimeout);
if (upper.StartsWith("USB"))
return new UsbSession(res, mode, openTimeout);
if (upper.StartsWith("ASRL") || upper.StartsWith("SERIAL"))
return new SerialSession(res, mode, openTimeout);
}
catch (Exception)
{
// 打开会话失败(设备未连接/资源被占用/驱动异常等):吞掉异常返回 null,交由上层按“未连接”处理
}
// 无法识别的资源类型同样返回 null,不抛异常
return null;
}
public virtual void Close()
{
DisposeSession();
}
/// <summary>线程安全地释放当前会话并置空(幂等)。</summary>
private void DisposeSession()
{
var s = Interlocked.Exchange(ref _session, null);
try { s?.Dispose(); } catch { /* 释放阶段忽略底层异常 */ }
}
/// <summary>确保会话已建立,否则抛出与 Tcp 一致语义的异常。</summary>
private void EnsureConnected()
{
if (_session == null) throw new InvalidOperationException("VISA 会话未连接。");
}
/// <summary>按分隔符设置读取终止字符(取分隔符最后一个字符,兼容 "\r\n")。</summary>
private void ApplyTermination(string? delimiter)
{
if (string.IsNullOrEmpty(delimiter)) return;
_session!.TerminationCharacter = (byte)delimiter[delimiter.Length - 1];
_session!.TerminationCharacterEnabled = true;
}
/// <summary>
/// 宽松解码设备回传的字节为字符串。
/// <para>NI-VISA 的 <c>RawIO.ReadString()</c> 采用严格解码,遇到非文本/高位字节会抛
/// <see cref="DecoderFallbackException"/>;而 SCPI 文本响应本质是 ASCII,
/// 故改用 <c>RawIO.Read()</c> 取原始字节再以 <c>Encoding.ASCII</c> 解码——
/// 非法字节被替换为 '?',绝不抛异常。</para>
/// </summary>
private static string DecodeBytes(byte[]? buffer)
=> buffer == null || buffer.Length == 0 ? string.Empty : Encoding.ASCII.GetString(buffer);
public async Task SendAsync(byte[] buffer, CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
try
{
EnsureConnected();
await Task.Run(() => _session!.RawIO.Write(buffer), ct);
}
finally
{
_commLock.Release();
}
}
public async Task SendAsync(string str, CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
try
{
EnsureConnected();
// RawIO.Write 原样下发,不追加结束符(SCPI 命令自带 \n)
await Task.Run(() => _session!.RawIO.Write(str), ct);
}
finally
{
_commLock.Release();
}
}
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);
}
finally
{
_commLock.Release();
}
}
public async Task<string> ReadAsync(string delimiter = "\n", CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
try
{
EnsureConnected();
ApplyTermination(delimiter);
// 读到终止符/END 为止(等价 Tcp 的按分隔符读取),再用宽松 ASCII 解码,避免 ReadString 严格解码抛 DecoderFallbackException
return await Task.Run(() => DecodeBytes(_session!.RawIO.Read()).Trim(), ct);
}
finally
{
_commLock.Release();
}
}
public async Task<string> WriteReadAsync(string command, string delimiter = "\n", CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
try
{
EnsureConnected();
ApplyTermination(delimiter);
return await Task.Run(() =>
{
_session!.RawIO.Write(command);
return DecodeBytes(_session!.RawIO.Read()).Trim();
}, ct);
}
finally
{
_commLock.Release();
}
}
/// <summary>
/// 发送查询命令并读取设备回传的 IEEE 488.2 定长二进制块,自动剥离块头(<c>#N&lt;长度&gt;</c>),只返回纯数据字节。
/// <para>示波器 <c>FILESystem:READFile</c> 回传的 PNG 截图、<c>CURVe?</c> 波形等均以该块格式封装:
/// <c>#9&lt;9位长度&gt;&lt;二进制数据&gt;&lt;LF&gt;</c>。若把含 <c>#9...</c> 头的整段原始字节直接写入 .png,
/// 文件将不以 PNG 魔数(<c>\x89PNG</c>)开头而被判为损坏——故必须解析并丢弃块头,只落地纯数据。</para>
/// <para>读取期间关闭终止符,避免数据中的 0x0A 被误判为结束;并按块头声明的长度精确读取,
/// 不再依赖 END 指示,从根本上规避大块传输的 <c>IOTimeoutException</c>。</para>
/// </summary>
public async Task<byte[]> ReadAllBytesAsync(string queryCommand, CancellationToken ct = default)
{
await _commLock.WaitAsync(ct);
try
{
EnsureConnected();
return await Task.Run(() => ReadIEEEBlock(queryCommand), ct);
}
finally
{
_commLock.Release();
}
}
/// <summary>
/// 同步读取一个 IEEE 488.2 定长二进制块(<c>#N&lt;长度&gt;&lt;数据&gt;&lt;终止符&gt;</c>)并返回其纯数据部分。
/// </summary>
private byte[] ReadIEEEBlock(string queryCommand)
{
bool prevTermEnabled = _session!.TerminationCharacterEnabled;
int prevTimeout = _session!.TimeoutMilliseconds;
// 二进制数据可能含 0x0A,读取全程禁用终止符,改为按长度精确读取
_session!.TerminationCharacterEnabled = false;
try
{
_session!.RawIO.Write(queryCommand);
// 1) 块头起始符 '#'(容忍前导空白/换行)
int first = ReadBlockStartByte();
if (first != '#')
{
// 非标准块:设备(如 Tektronix FILESystem:READFile 回传 PNG 截图)直接回裸二进制,不带 IEEE #N 块头。
// 单次 RawIO.Read() 只返回首个内部缓冲(约 1KB)即结束,会把大文件截断(表现为截图只有 1024/1025 字节)。
// 参考 ADP TCP “读到没有为止” 的策略,这里按 VISA 原生 END 指示分块循环读取,直到传输真正结束,并把已取出的首字节并回。
return PrependByte((byte)first, ReadRawUntilEnd(prevTimeout));
}
// 2) 长度位数 N('1'-'9';'0' 为不定长块)
int nDigits = ReadOneByte() - '0';
if (nDigits <= 0)
{
// 不定长块 #0<data><terminator>:启用终止符,读到换行为止
_session!.TerminationCharacter = (byte)'\n';
_session!.TerminationCharacterEnabled = true;
return _session!.RawIO.Read();
}
// 3) N 位十进制长度
byte[] lenBytes = _session!.RawIO.Read(nDigits);
string lenText = Encoding.ASCII.GetString(lenBytes).Trim();
if (!long.TryParse(lenText, out long dataLen) || dataLen < 0)
throw new InvalidOperationException($"IEEE 块头长度字段解析失败:'{lenText}'。");
if (dataLen == 0) return Array.Empty<byte>();
// 4) 精确读取 dataLen 字节(分块补足);大块传输放宽超时,避免 IOTimeoutException
_session!.TimeoutMilliseconds = Math.Max(prevTimeout, 30000);
var buffer = new byte[dataLen];
long offset = 0;
while (offset < dataLen)
{
byte[] chunk = _session!.RawIO.Read(dataLen - offset);
if (chunk == null || chunk.Length == 0) break;
Buffer.BlockCopy(chunk, 0, buffer, (int)offset, chunk.Length);
offset += chunk.Length;
}
// 5) 消费块尾终止符(LF/CRLF),短超时避免无尾符时长时间阻塞,保证后续命令读取同步
_session!.TimeoutMilliseconds = 500;
_session!.TerminationCharacter = (byte)'\n';
_session!.TerminationCharacterEnabled = true;
try { _session!.RawIO.ReadString(); } catch { /* 无尾随终止符则忽略 */ }
if (offset == dataLen) return buffer;
// 实际读到的字节少于声明长度:按实到长度截断返回,避免尾部填充 0
var trimmed = new byte[offset];
Buffer.BlockCopy(buffer, 0, trimmed, 0, (int)offset);
return trimmed;
}
finally
{
_session!.TimeoutMilliseconds = prevTimeout;
_session!.TerminationCharacterEnabled = prevTermEnabled;
}
}
/// <summary>读取 1 个字节并返回其整数值(0-255);无数据则抛异常。</summary>
private int ReadOneByte()
{
byte[] b = _session!.RawIO.Read(1);
if (b == null || b.Length == 0)
throw new InvalidOperationException("读取 IEEE 块头时未收到任何字节。");
return b[0];
}
/// <summary>跳过前导空白/换行后读取块头起始字节,返回其整数值。</summary>
private int ReadBlockStartByte()
{
int b = ReadOneByte();
for (int guard = 0; (b == '\n' || b == '\r' || b == ' ' || b == '\t') && guard < 64; guard++)
b = ReadOneByte();
return b;
}
/// <summary>把首字节并回数组前部(用于设备直接回裸数据的退化路径)。</summary>
private static byte[] PrependByte(byte first, byte[]? rest)
{
rest ??= Array.Empty<byte>();
var result = new byte[rest.Length + 1];
result[0] = first;
Buffer.BlockCopy(rest, 0, result, 1, rest.Length);
return result;
}
/// <summary>
/// 循环读取设备回传的裸二进制数据,直到收到 END 指示或终止符为止(参考 ADP TCP “读到没有为止” 策略)。
/// <para>无参 <c>RawIO.Read()</c> 只返回首个内部缓冲(约 1KB)即结束,会把大文件(如 PNG 截图)截断;
/// 本方法改用 <c>RawIO.Read(count, out ReadStatus)</c> 分块读取,依据 <see cref="Ivi.Visa.ReadStatus"/> 判定结束:
/// <c>EndReceived</c> / <c>TerminationCharacterEncountered</c> 表示传输完成,
/// <c>MaximumCountReached</c> 表示本次读满、可能仍有数据需继续读取。</para>
/// </summary>
/// <param name="prevTimeout">调用前的 I/O 超时(毫秒);大块传输期间在此放宽,读取结束后由调用方 finally 统一恢复。</param>
/// <returns>设备回传的完整裸二进制字节。</returns>
private byte[] ReadRawUntilEnd(int prevTimeout)
{
// 裸二进制可能含 0x0A,全程禁用终止符;大块传输放宽超时,避免 IOTimeoutException
_session!.TerminationCharacterEnabled = false;
_session!.TimeoutMilliseconds = Math.Max(prevTimeout, 30000);
using var ms = new MemoryStream();
const long chunkSize = 65536; // 64KB 分块,兼顾吞吐与内存占用
while (true)
{
byte[] chunk;
Ivi.Visa.ReadStatus status;
try
{
chunk = _session.RawIO.Read(chunkSize, out status);
}
catch (Exception) when (ms.Length > 0)
{
// 已收到部分数据后再发生超时等异常:视为传输结束,返回已收数据
// (兼容个别不发送 END 指示的 TCPIP SOCKET 链路)
break;
}
if (chunk != null && chunk.Length > 0)
ms.Write(chunk, 0, chunk.Length);
// 收到 END 或终止符 = 设备已发完,传输结束
if (status == Ivi.Visa.ReadStatus.EndReceived ||
status == Ivi.Visa.ReadStatus.TerminationCharacterEncountered)
break;
// 缓冲读满但未见 END = 后面可能还有数据,继续下一轮读取
if (status == Ivi.Visa.ReadStatus.MaximumCountReached)
continue;
// 其余状态且本轮未读到任何字节:判定结束,避免无数据时空转
if (chunk == null || chunk.Length == 0)
break;
}
return ms.ToArray();
}
public void Dispose()
{
DisposeSession();
_commLock?.Dispose();
GC.SuppressFinalize(this);
}
}
}
+1
View File
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="NModbus" Version="3.0.83" />
<PackageReference Include="NModbus.Serial" Version="3.0.83" />
<PackageReference Include="NationalInstruments.Visa" Version="25.3.0.11" />
</ItemGroup>
<ItemGroup>
+18 -85
View File
@@ -4,7 +4,6 @@ using Model.Models;
using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -55,10 +54,11 @@ namespace DeviceCommand.Devices
#endregion
/// <summary>
/// Tektronix 示波器 (MSO 4/5/6 系列),支持采集控制、通道设置、自动测量与截图导出
/// Tektronix 示波器 (MSO 4/5/6 系列),支持采集控制、通道设置、自动测量与截图导出。
/// 传输层为 VISA(资源字符串寻址,如 TCPIP0::IP::inst0::INSTR),SCPI 指令集不变。
/// </summary>
[ACPCommand]
public class TektronixMSO : Tcp
public class TektronixMSO : Visa
{
// SCPI 指令结束符 (消息终止符需使用 LF)
private const string ScpiDelimiter = "\n";
@@ -102,7 +102,7 @@ namespace DeviceCommand.Devices
#endregion
public TektronixMSO(TcpConfig config) : base(config)
public TektronixMSO(VisaConfig config) : base(config)
{
}
@@ -327,103 +327,36 @@ namespace DeviceCommand.Devices
#region 6. 屏幕截图与文件系统 (Hard Copy & File System)
/// <summary>
/// 捕获示波器屏幕并保存到上位机本地路径
/// 捕获示波器当前屏幕并保存为 PNG 到上位机本地路径 (SAVe:IMAGe / FILESystem:READFile 指令)。
/// <para>执行流程:① SAVe:IMAGe 将屏幕截图写入示波器本地临时文件;② *OPC? 阻塞等待写盘完成;
/// ③ FILESystem:READFile 以 IEEE 488.2 定长块(#9&lt;长度&gt;&lt;数据&gt;)回传文件,基类自动剥离块头后返回纯 PNG 字节;
/// ④ 写入上位机本地磁盘;⑤ 删除示波器临时文件,保持设备存储干净。</para>
/// </summary>
/// <param name="上位机文件路径">保存到电脑本地的文件路径 (例如: @"C:\temp\screen.png")</param>
/// <param name="ct">异步取消令牌</param>
public virtual async Task 保存屏幕截图(string 上位机文件路径, CancellationToken ct = default)
{
string 临时文件 = "E:/temp1.png";
// 示波器端临时文件:截图先落到设备本地,再回传上位机
const string 临时文件 = "E:/Temp.png";
// 1. 直接保存截图到示波器U盘 (自动识别 .png 扩展名)
// 1. 命令示波器把当前屏幕保存为 PNG 到设备本地临时文件
await SendAsync($"SAVe:IMAGe \"{临时文件}\"{ScpiDelimiter}", ct);
// 2. 发送 *OPC? 并等待返回 1,确保图像文件已经完全写入磁盘
// 2. 发送 *OPC? 并等待返回,确保图像文件已完全写入磁盘
await WriteReadAsync($"*OPC?{ScpiDelimiter}", ScpiDelimiter, ct);
// 3. 将文件内容读取到当前通信接口
await SendAsync($"FILESystem:READFile \"{临时文件}\"{ScpiDelimiter}", ct);
// 3. 读取示波器临时文件回传的 IEEE 488.2 定长块(基类自动剥离 #9 块头,返回纯 PNG 字节)
byte[] imageBytes = await ReadAllBytesAsync($"FILESystem:READFile \"{临时文件}\"{ScpiDelimiter}", ct);
// 4. 提取底层的二进制块 (Block Data)
byte[] imageBytes = await ReadBinaryBlockAsync(ct);
// 5. 将提取的二进制字节流写入上位机本地磁盘
// 4. 确保本地目录存在后,将二进制字节流写入上位机磁盘
string? 目录 = Path.GetDirectoryName(上位机文件路径);
if (!string.IsNullOrEmpty(目录)) Directory.CreateDirectory(目录);
await File.WriteAllBytesAsync(上位机文件路径, imageBytes, ct);
// 6. 删除示波器上的临时文件,保持设备存储干净
// 5. 删除示波器上的临时文件,保持设备存储干净
await SendAsync($"FILESystem:DELEte \"{临时文件}\"{ScpiDelimiter}", ct);
}
/// <summary>
/// ⚠️ 请在这里将您的网络流对接起来!
/// 依据您 Tcp 基类的设计,返回用于读取底层字节流的 Stream。
/// </summary>
protected virtual Stream GetNetworkStream()
{
// 【修改提示】:
// 如果您的基类暴露了 TcpClient,请取消注释并使用这一行:
// return this.TcpClient.GetStream();
// 如果您的基类直接叫 _stream 或 Stream,请返回它:
// return this.Stream;
// 假如不知道怎么填,请查看 Tcp 类的源码。
throw new NotImplementedException("请在此方法中返回基类的 NetworkStream 或 Socket 流对象。");
}
/// <summary>
/// 辅助方法:读取 IEEE 488.2 标准的二进制块数据 (Block Data)
/// </summary>
protected virtual async Task<byte[]> ReadBinaryBlockAsync(CancellationToken ct = default)
{
var stream = GetNetworkStream();
byte[] singleByte = new byte[1];
// 1. 逐字节读取,直到读到起始符 '#'
while (true)
{
int read = await stream.ReadAsync(singleByte, 0, 1, ct);
if (read == 0) throw new EndOfStreamException("通信中断:未找到二进制块起始符 '#'");
if (singleByte[0] == (byte)'#') break;
}
// 2. 读取 1 个字符 <NZDig>,它代表接下来有多少个字符表示数据长度
await stream.ReadAsync(singleByte, 0, 1, ct);
char nzDigChar = (char)singleByte[0];
if (!char.IsDigit(nzDigChar)) throw new InvalidDataException($"无效的块头标识: {nzDigChar}");
int lengthBytesCount = int.Parse(nzDigChar.ToString());
// 3. 读取 <NZDig> 所指示的长度字符,将其解析为整数 N
byte[] lengthBytes = new byte[lengthBytesCount];
int currentRead = 0;
while (currentRead < lengthBytesCount)
{
int read = await stream.ReadAsync(lengthBytes, currentRead, lengthBytesCount - currentRead, ct);
if (read == 0) throw new EndOfStreamException("通信中断:读取数据长度失败");
currentRead += read;
}
int dataLength = int.Parse(Encoding.ASCII.GetString(lengthBytes));
// 4. 从流中精确读取 N 个字节 <DChar>...,这就是完整的图像数据
byte[] imageData = new byte[dataLength];
int totalBytesRead = 0;
while (totalBytesRead < dataLength)
{
int read = await stream.ReadAsync(imageData, totalBytesRead, dataLength - totalBytesRead, ct);
if (read == 0) throw new EndOfStreamException("通信中断:读取图像数据不完整");
totalBytesRead += read;
}
// 5. 结尾可能包含 <EOM> (例如 LF \n),将其读取并丢弃以清空缓冲区
try
{
await stream.ReadAsync(singleByte, 0, 1, ct);
}
catch { /* 忽略末尾符读取的可能超时 */ }
return imageData;
}
#endregion
#region 7. 状态与错误队列 (Status and Error)
+2
View File
@@ -23,6 +23,7 @@ namespace DeviceEditModule
containerRegistry.RegisterForNavigation<MCc30WView>("MCc30WView");
containerRegistry.RegisterForNavigation<RLT1000View>("RLT1000View");
containerRegistry.RegisterForNavigation<S7200View>("S7200View");
containerRegistry.RegisterForNavigation<TektronixMSOView>("TektronixMSOView");
containerRegistry.Register<DG1000ZViewModel>();
containerRegistry.Register<IT6720ViewModel>();
containerRegistry.Register<PW8001ViewModel>();
@@ -31,6 +32,7 @@ namespace DeviceEditModule
containerRegistry.Register<MCc30WViewModel>();
containerRegistry.Register<RLT1000ViewModel>();
containerRegistry.Register<S7200ViewModel>();
containerRegistry.Register<TektronixMSOViewModel>();
}
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace Model.Models
{
/// <summary>
/// VISA 通信参数(DeviceCommand 内部纯数据类,供设备类构造函数使用)。
/// 与 UIShare.UIViewModel.VisaConfigVM 字段一一对应,由 DeviceManager 在实例化时填充。
/// <para>VISA 通过统一的资源字符串寻址,无需区分 IP/端口:
/// TCPIP0::192.168.20.125::inst0::INSTR、USB0::0x0699::0x0408::C012345::INSTR、GPIB0::7::INSTR 等。</para>
/// </summary>
public class VisaConfig
{
/// <summary>VISA 资源字符串(由 NI-VISA 解析并自动选择对应的传输层)。</summary>
public string ResourceName { get; set; } = "TCPIP0::127.0.0.1::inst0::INSTR";
/// <summary>打开 VISA 会话的最长等待时间(毫秒)。</summary>
public int OpenTimeout { get; set; } = 3000;
/// <summary>读写 I/O 超时(毫秒),对应 VISA 的 TimeoutMilliseconds。</summary>
public int IOTimeout { get; set; } = 3000;
}
}
+1
View File
@@ -19,6 +19,7 @@ namespace SettingModule
// 设备连接配置弹窗
containerRegistry.RegisterDialog<TCPConfigView>("TCPConfig");
containerRegistry.RegisterDialog<VisaConfigView>("VisaConfig");
containerRegistry.RegisterDialog<SerialPortConfigView>("SerialPortConfig");
containerRegistry.RegisterDialog<CANConfigView>("CANConfig");
}
@@ -0,0 +1,138 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using UIShare.PubEvent;
using UIShare.UIViewModel;
using UIShare.ViewModelBase;
namespace SettingModule.ViewModels.Dialogs
{
/// <summary>
/// VISA 连接配置对话框 VM。
/// 通过 DialogParameters 接收宿主 DeviceInfoVM;保存时把副本写回宿主。
/// </summary>
public class VisaConfigViewModel : DialogViewModelBase
{
#region 属性
private string _title = "VISA 连接配置";
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
/// <summary>编辑用的副本,取消时不会污染宿主对象。</summary>
private VisaConfigVM _config = new();
public VisaConfigVM Config
{
get => _config;
set => SetProperty(ref _config, value);
}
/// <summary>常用超时(毫秒)。</summary>
public ObservableCollection<int> CommonTimeouts { get; } = new()
{
500, 1000, 2000, 3000, 5000, 10000
};
/// <summary>常见 VISA 资源字符串模板,便于快速填写(可直接编辑)。</summary>
public ObservableCollection<string> ResourceTemplates { get; } = new()
{
"TCPIP0::192.168.20.125::inst0::INSTR",
"TCPIP0::192.168.20.125::5025::SOCKET",
"USB0::0x0699::0x0408::C012345::INSTR",
"GPIB0::7::INSTR",
"ASRL1::INSTR"
};
private string _errorMessage = string.Empty;
public string ErrorMessage
{
get => _errorMessage;
set => SetProperty(ref _errorMessage, value);
}
#endregion
#region 命令
public ICommand SaveCommand { get; }
public ICommand CancelCommand { get; }
#endregion
// 用于保存时把副本回写到原对象
private DeviceInfoVM? _hostDevice;
public VisaConfigViewModel(IContainerProvider containerProvider) : base(containerProvider)
{
SaveCommand = new DelegateCommand(OnSave);
CancelCommand = new DelegateCommand(OnCancel);
}
private bool Validate(out string error)
{
error = string.Empty;
if (string.IsNullOrWhiteSpace(Config.ResourceName))
{
error = "VISA 资源字符串不能为空";
return false;
}
if (!Config.ResourceName.Contains("::"))
{
error = "资源字符串格式不正确,应形如 TCPIP0::192.168.0.10::inst0::INSTR";
return false;
}
if (Config.OpenTimeout < 0 || Config.IOTimeout < 0)
{
error = "超时时间不能为负数";
return false;
}
return true;
}
private void OnSave()
{
if (!Validate(out var error))
{
ErrorMessage = error;
return;
}
ErrorMessage = string.Empty;
// 把副本写回宿主
if (_hostDevice != null)
{
_hostDevice.VisaConfig ??= new VisaConfigVM();
Config.CopyTo(_hostDevice.VisaConfig);
_hostDevice.ConnectionType = "Visa";
}
RequestClose.Invoke(ButtonResult.OK);
}
private void OnCancel() => RequestClose.Invoke(ButtonResult.Cancel);
#region Prism Dialog 规范
public override void OnDialogOpened(IDialogParameters parameters)
{
_eventAggregator.GetEvent<OverlayEvent>().Publish(true);
if (parameters.ContainsKey("Device"))
{
_hostDevice = parameters.GetValue<DeviceInfoVM>("Device");
Title = $"VISA 连接配置 - {_hostDevice?.DeviceName}";
Config = new VisaConfigVM(_hostDevice?.VisaConfig);
}
else if (parameters.ContainsKey("Config"))
{
Config = new VisaConfigVM(parameters.GetValue<VisaConfigVM>("Config"));
}
}
public override void OnDialogClosed()
{
_eventAggregator.GetEvent<OverlayEvent>().Publish(false);
}
#endregion
}
}
+2 -1
View File
@@ -59,7 +59,7 @@ namespace SettingModule.ViewModels
public ObservableCollection<string> ConnectionTypes { get; } = new()
{
"None", "Tcp", "Serial", "CAN"
"None", "Tcp", "Visa", "Serial", "CAN"
};
#endregion
#region 命令
@@ -163,6 +163,7 @@ namespace SettingModule.ViewModels
var dialogName = SelectedDevice.ConnectionType switch
{
"Tcp" => "TCPConfig",
"Visa" => "VisaConfig",
"Serial" => "SerialPortConfig",
"CAN" => "CANConfig",
_ => string.Empty
@@ -0,0 +1,112 @@
<UserControl x:Class="SettingModule.Views.Dialogs.VisaConfigView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SettingModule.Views.Dialogs"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:helpers="clr-namespace:UIShare.Helpers;assembly=UIShare"
xmlns:converters="clr-namespace:UIShare.Converters;assembly=UIShare"
mc:Ignorable="d"
xmlns:prism="http://prismlibrary.com/"
Background="White"
prism:ViewModelLocator.AutoWireViewModel="True"
Width="500"
Height="300">
<prism:Dialog.WindowStyle>
<Style BasedOn="{StaticResource DialogUserManageStyle}"
TargetType="Window" />
</prism:Dialog.WindowStyle>
<UserControl.Resources>
<converters:StringToVisibilityConverter x:Key="StringToVisibility"/>
</UserControl.Resources>
<GroupBox Padding="12,8,12,8"
helpers:WindowDragHelper.EnableWindowDrag="True">
<GroupBox.Header>
<Grid Margin="0">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="*" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Title}"
Foreground="White"
VerticalAlignment="Center"
Margin="5,0,10,0" />
</Grid>
</GroupBox.Header>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 表单 -->
<Grid Grid.Row="0" Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0"
Text="资源字符串:"
VerticalAlignment="Center" Margin="0,6"/>
<ComboBox Grid.Row="0" Grid.Column="1" Margin="0,6"
IsEditable="True"
materialDesign:HintAssist.Hint="TCPIP0::IP::inst0::INSTR"
ItemsSource="{Binding ResourceTemplates}"
Text="{Binding Config.ResourceName, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Grid.Row="1" Grid.Column="0"
Text="打开超时(ms):"
VerticalAlignment="Center" Margin="0,6"/>
<ComboBox Grid.Row="1" Grid.Column="1" Margin="0,6"
IsEditable="True"
materialDesign:HintAssist.Hint=""
ItemsSource="{Binding CommonTimeouts}"
Text="{Binding Config.OpenTimeout, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Grid.Row="2" Grid.Column="0"
Text="读写超时(ms):"
VerticalAlignment="Center" Margin="0,6"/>
<ComboBox Grid.Row="2" Grid.Column="1" Margin="0,6"
IsEditable="True"
materialDesign:HintAssist.Hint=""
ItemsSource="{Binding CommonTimeouts}"
Text="{Binding Config.IOTimeout, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<!-- 错误提示 -->
<TextBlock Grid.Row="1"
Margin="0,8,0,0"
Foreground="#D32F2F"
TextWrapping="Wrap"
Text="{Binding ErrorMessage}"
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibility}}"/>
<!-- 按钮 -->
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,12,0,0">
<Button Content="取消"
Width="80" Padding="0,4"
Command="{Binding CancelCommand}"/>
<Button Content="保存"
Width="80" Padding="0,4"
Margin="10,0,0,0"
IsDefault="True"
Command="{Binding SaveCommand}"/>
</StackPanel>
</Grid>
</GroupBox>
</UserControl>
@@ -0,0 +1,15 @@
using System.Windows.Controls;
namespace SettingModule.Views.Dialogs
{
/// <summary>
/// VisaConfigView.xaml 的交互逻辑
/// </summary>
public partial class VisaConfigView : UserControl
{
public VisaConfigView()
{
InitializeComponent();
}
}
}
@@ -124,6 +124,11 @@ namespace UIShare.GlobalVariable
{
alive = await ProbeTcpDeviceAsync(tcpDevice);
}
// VISA 设备同理:IsConnected 仅表示会话对象存在,主动探活以检测死会话
else if (alive && device is Visa visaDevice)
{
alive = await ProbeVisaDeviceAsync(visaDevice);
}
if (alive)
{
@@ -176,6 +181,26 @@ namespace UIShare.GlobalVariable
}
}
/// <summary>
/// 对 VISA 设备执行轻量级主动探活(SCPI *IDN?)。
/// <para>通过 WriteReadAsync 内部获取 _commLock,与监控采样天然串行化;
/// 死会话将在 VISA 自身的 IOTimeout 内抛出异常,返回 false 触发重连。</para>
/// </summary>
/// <returns>true: 探活成功(会话确实存活); false: 探活失败(死会话)</returns>
private async Task<bool> ProbeVisaDeviceAsync(Visa visaDevice)
{
try
{
using var probeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
string resp = await visaDevice.WriteReadAsync("*IDN?\n", "\n", probeCts.Token);
return !string.IsNullOrWhiteSpace(resp);
}
catch
{
return false;
}
}
/// <summary>
/// 重连单个设备。
/// <para>
+53
View File
@@ -68,6 +68,12 @@ namespace UIShare.GlobalVariable
return $"Tcp:{config.TcpConfig.IPAddress}:{config.TcpConfig.Port}";
}
if (string.Equals(config.ConnectionType, "Visa", StringComparison.OrdinalIgnoreCase)
&& config.VisaConfig != null)
{
return $"Visa:{config.VisaConfig.ResourceName}";
}
if (string.Equals(config.ConnectionType, "Serial", StringComparison.OrdinalIgnoreCase)
&& config.SerialPortConfig != null)
{
@@ -129,6 +135,7 @@ namespace UIShare.GlobalVariable
return config.ConnectionType switch
{
"Tcp" => CreateTcpDevice(deviceType, config.TcpConfig)!,
"Visa" => CreateVisaDevice(deviceType, config.VisaConfig)!,
"Serial" => CreateSerialDevice(deviceType, config.SerialPortConfig)!,
_ => null!
};
@@ -379,6 +386,7 @@ namespace UIShare.GlobalVariable
bool ok = conn switch
{
"Tcp" => await ConnectTcpAsync(name, device, ct),
"Visa" => await ConnectVisaAsync(name, device, ct),
"Serial" => await ConnectSerialAsync(name, device, ct),
_ => false
};
@@ -417,6 +425,16 @@ namespace UIShare.GlobalVariable
return false;
}
private static async Task<bool> ConnectVisaAsync(string name, IBaseInterface device, CancellationToken ct)
{
if (device is IVisa visa)
{
return await visa.ConnectAsync(ct);
}
LoggerHelper.Warn($"设备 [{name}] 配置为 Visa 但未实现 IVisa,实际类型为 {device.GetType().Name}。");
return false;
}
private static async Task<bool> ConnectSerialAsync(string name, IBaseInterface device, CancellationToken ct)
{
if (device is not ISerialPort sp)
@@ -533,8 +551,35 @@ namespace UIShare.GlobalVariable
SendTimeout = vm.SendTimeout,
ReceiveTimeout = vm.ReceiveTimeout
};
try
{
return Activator.CreateInstance(type, cfg) as IBaseInterface;
}
catch (Exception ex)
{
LoggerHelper.Warn($"实例化 Tcp 设备 [{type.Name}] 失败(连接配置与设备构造函数不匹配?):{ex.InnerException?.Message ?? ex.Message}");
return null;
}
}
private static IBaseInterface? CreateVisaDevice(Type type, VisaConfigVM? vm)
{
vm ??= new VisaConfigVM();
var cfg = new VisaConfig
{
ResourceName = vm.ResourceName,
OpenTimeout = vm.OpenTimeout,
IOTimeout = vm.IOTimeout
};
try
{
return Activator.CreateInstance(type, cfg) as IBaseInterface;
}
catch (Exception ex)
{
LoggerHelper.Warn($"实例化 Visa 设备 [{type.Name}] 失败(连接配置与设备构造函数不匹配?):{ex.InnerException?.Message ?? ex.Message}");
return null;
}
}
private static IBaseInterface? CreateSerialDevice(Type type, SerialPortConfigVM? vm)
{
vm ??= new SerialPortConfigVM();
@@ -548,8 +593,16 @@ namespace UIShare.GlobalVariable
ReadTimeout = vm.ReadTimeout,
WriteTimeout = vm.WriteTimeout
};
try
{
return Activator.CreateInstance(type, cfg) as IBaseInterface;
}
catch (Exception ex)
{
LoggerHelper.Warn($"实例化 Serial 设备 [{type.Name}] 失败(连接配置与设备构造函数不匹配?):{ex.InnerException?.Message ?? ex.Message}");
return null;
}
}
public void Dispose()
{
+8
View File
@@ -61,6 +61,14 @@ namespace UIShare.UIViewModel
set => SetProperty(ref _tcpConfig, value);
}
/// <summary>VISA 连接参数(首次访问时自动初始化,便于 XAML 直接绑定)。</summary>
private VisaConfigVM _visaConfig = new();
public VisaConfigVM VisaConfig
{
get => _visaConfig;
set => SetProperty(ref _visaConfig, value);
}
/// <summary>串口连接参数(首次访问时自动初始化,便于 XAML 直接绑定)。</summary>
private SerialPortConfigVM _serialPortConfig = new();
public SerialPortConfigVM SerialPortConfig
+56
View File
@@ -0,0 +1,56 @@
using Prism.Mvvm;
namespace UIShare.UIViewModel
{
/// <summary>
/// VISA 连接配置(与 DeviceCommand.Base.Visa 保持字段一致)。
/// <para>VISA 通过统一资源字符串寻址,无需区分 IP/端口,例如
/// TCPIP0::192.168.20.125::inst0::INSTR、USB0::0x0699::0x0408::C012345::INSTR。</para>
/// </summary>
public class VisaConfigVM : BindableBase
{
private string _resourceName = "TCPIP0::127.0.0.1::inst0::INSTR";
/// <summary>VISA 资源字符串(TCPIP / USB / GPIB / ASRL 等统一寻址)。</summary>
public string ResourceName
{
get => _resourceName;
set => SetProperty(ref _resourceName, value);
}
private int _openTimeout = 3000;
/// <summary>打开会话超时(毫秒)。</summary>
public int OpenTimeout
{
get => _openTimeout;
set => SetProperty(ref _openTimeout, value);
}
private int _ioTimeout = 3000;
/// <summary>读写 I/O 超时(毫秒)。</summary>
public int IOTimeout
{
get => _ioTimeout;
set => SetProperty(ref _ioTimeout, value);
}
public VisaConfigVM() { }
/// <summary>拷贝构造,用于对话框编辑副本。</summary>
public VisaConfigVM(VisaConfigVM? src)
{
if (src == null) return;
ResourceName = src.ResourceName;
OpenTimeout = src.OpenTimeout;
IOTimeout = src.IOTimeout;
}
/// <summary>把字段拷回目标对象(保存时用)。</summary>
public void CopyTo(VisaConfigVM? dst)
{
if (dst == null) return;
dst.ResourceName = ResourceName;
dst.OpenTimeout = OpenTimeout;
dst.IOTimeout = IOTimeout;
}
}
}