Files
ACP/DeviceCommand/Base/Visa.cs
T

427 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}