From 81c6386e6284726393a10fed6eea7c29829ce481 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9Chsc=E2=80=9D?= <“huangsucan@kaiyili-lab.com”>
Date: Tue, 22 Sep 2026 15:19:24 +0800
Subject: [PATCH] =?UTF-8?q?=E7=A4=BA=E6=B3=A2=E5=99=A8=E4=BF=AE=E6=94=B9?=
=?UTF-8?q?=EF=BC=9Avisa=E8=BF=9E=E6=8E=A5=E6=96=B9=E5=BC=8F+=E6=88=AA?=
=?UTF-8?q?=E5=9B=BE=E4=BF=9D=E5=AD=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
DeviceCommand/Base/IVisa.cs | 31 ++
DeviceCommand/Base/Visa.cs | 426 ++++++++++++++++++
DeviceCommand/DeviceCommand.csproj | 1 +
DeviceCommand/Devices/TektronixMSO.cs | 103 +----
DeviceEditModule/DeviceEditModule.cs | 2 +
Model/Models/VisaConfig.cs | 20 +
SettingModule/SettingModule.cs | 1 +
.../ViewModels/Dialogs/VisaConfigViewModel.cs | 138 ++++++
SettingModule/ViewModels/SettingViewModel.cs | 3 +-
.../Views/Dialogs/VisaConfigView.xaml | 112 +++++
.../Views/Dialogs/VisaConfigView.xaml.cs | 15 +
UIShare/GlobalVariable/DeviceHealthMonitor.cs | 25 +
UIShare/GlobalVariable/DeviceManager.cs | 57 ++-
UIShare/UIViewModel/DeviceInfoVM.cs | 8 +
UIShare/UIViewModel/VisaConfigVM.cs | 56 +++
15 files changed, 910 insertions(+), 88 deletions(-)
create mode 100644 DeviceCommand/Base/IVisa.cs
create mode 100644 DeviceCommand/Base/Visa.cs
create mode 100644 Model/Models/VisaConfig.cs
create mode 100644 SettingModule/ViewModels/Dialogs/VisaConfigViewModel.cs
create mode 100644 SettingModule/Views/Dialogs/VisaConfigView.xaml
create mode 100644 SettingModule/Views/Dialogs/VisaConfigView.xaml.cs
create mode 100644 UIShare/UIViewModel/VisaConfigVM.cs
diff --git a/DeviceCommand/Base/IVisa.cs b/DeviceCommand/Base/IVisa.cs
new file mode 100644
index 0000000..91426de
--- /dev/null
+++ b/DeviceCommand/Base/IVisa.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DeviceCommand.Base
+{
+ ///
+ /// VISA SCPI 通信接口:与 暴露一致的异步收发能力,
+ /// 使 SCPI 设备类可在 Tcp 与 VISA 传输之间平滑切换。
+ ///
+ public interface IVisa : IBaseInterface, IDisposable
+ {
+ /// 发送原始二进制数据。
+ Task SendAsync(byte[] buffer, CancellationToken ct = default);
+
+ /// 发送字符串命令(SCPI 命令自带结束符)。
+ Task SendAsync(string str, CancellationToken ct = default);
+
+ /// 读取指定长度的原始字节。
+ Task ReadAsync(int length, CancellationToken ct = default);
+
+ /// 读取一行字符串,直到遇到结束符。
+ Task ReadAsync(string delimiter = "\n", CancellationToken ct = default);
+
+ /// 发送命令并读取一行响应。
+ Task WriteReadAsync(string command, string delimiter = "\n", CancellationToken ct = default);
+
+ /// 发送查询命令并读取 IEEE 488.2 定长二进制块,自动剥离块头(#N<长度>)后只返回纯数据字节(用于截图/波形等大块数据)。
+ Task ReadAllBytesAsync(string queryCommand, CancellationToken ct = default);
+ }
+}
diff --git a/DeviceCommand/Base/Visa.cs b/DeviceCommand/Base/Visa.cs
new file mode 100644
index 0000000..59380e1
--- /dev/null
+++ b/DeviceCommand/Base/Visa.cs
@@ -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
+{
+ ///
+ /// VISA SCPI 通信基类:基于 NI-VISA(NationalInstruments.Visa)实现,
+ /// 对外暴露与 完全一致的异步 API(SendAsync / ReadAsync / WriteReadAsync / ReadAllBytesAsync),
+ /// 使 SCPI 设备类(如 TektronixMSO)能在 Tcp 与 VISA 传输之间平滑切换。
+ /// VISA 通过统一的资源字符串寻址(TCPIP0::IP::inst0::INSTR、USB0::...、GPIB0::...::INSTR 等),
+ /// 由 NI-VISA 运行时自动选择对应传输层;本类只面向消息型会话 。
+ /// 注意:VISA 的读写为同步阻塞调用,本基类统一通过 投递到线程池,
+ /// 以保持与 Tcp 一致的 async/await 契约、避免阻塞 UI 线程。
+ ///
+ public class Visa : IVisa, IDisposable
+ {
+ /// VISA 资源字符串(例如 TCPIP0::192.168.20.125::inst0::INSTR)。
+ public string ResourceName { get; set; } = "TCPIP0::127.0.0.1::inst0::INSTR";
+
+ /// 打开 VISA 会话的最长等待时间(毫秒)。
+ public int OpenTimeout { get; set; } = 3000;
+
+ /// 读写 I/O 超时(毫秒),对应 VISA 的 TimeoutMilliseconds。
+ public int IOTimeout { get; set; } = 3000;
+
+ /// 底层 VISA 消息型会话;为 null 表示未连接。
+ private MessageBasedSession? _session;
+
+ /// 通信互斥锁:VISA 会话不支持并发读写,所有 I/O 串行化。
+ protected readonly SemaphoreSlim _commLock = new(1, 1);
+
+ public bool IsConnected => _session != null;
+
+ public Visa()
+ {
+ }
+
+ ///
+ /// 通过 一次性配置 VISA 通信参数。
+ ///
+ 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 ConnectAsync(CancellationToken ct = default)
+ {
+ await _commLock.WaitAsync(ct);
+ try
+ {
+ return await Task.Run(() => ResetConnection(), ct);
+ }
+ finally
+ {
+ _commLock.Release();
+ }
+ }
+
+ ///
+ /// 核心内部方法:释放旧会话并按资源字符串重建 VISA 会话。
+ ///
+ 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;
+ }
+
+ ///
+ /// 按 VISA 资源字符串前缀创建对应的具体会话实例;创建失败时返回 null(不抛异常)。
+ /// NI-VISA .NET 的基类 构造函数为 protected,无法直接实例化,
+ /// 必须使用其 public 构造函数的具体子类(TcpipSession/GpibSession/UsbSession/SerialSession),
+ /// 它们均派生自 ,故可统一以基类引用持有。
+ /// 资源字符串为空、类型无法识别或底层打开会话失败(如示波器未连接)时一律返回 null,
+ /// 由 按“未连接”处理,避免设备缺省时抛异常中断上层初始化。
+ ///
+ 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();
+ }
+
+ /// 线程安全地释放当前会话并置空(幂等)。
+ private void DisposeSession()
+ {
+ var s = Interlocked.Exchange(ref _session, null);
+ try { s?.Dispose(); } catch { /* 释放阶段忽略底层异常 */ }
+ }
+
+ /// 确保会话已建立,否则抛出与 Tcp 一致语义的异常。
+ private void EnsureConnected()
+ {
+ if (_session == null) throw new InvalidOperationException("VISA 会话未连接。");
+ }
+
+ /// 按分隔符设置读取终止字符(取分隔符最后一个字符,兼容 "\r\n")。
+ private void ApplyTermination(string? delimiter)
+ {
+ if (string.IsNullOrEmpty(delimiter)) return;
+ _session!.TerminationCharacter = (byte)delimiter[delimiter.Length - 1];
+ _session!.TerminationCharacterEnabled = true;
+ }
+
+ ///
+ /// 宽松解码设备回传的字节为字符串。
+ /// NI-VISA 的 RawIO.ReadString() 采用严格解码,遇到非文本/高位字节会抛
+ /// ;而 SCPI 文本响应本质是 ASCII,
+ /// 故改用 RawIO.Read() 取原始字节再以 Encoding.ASCII 解码——
+ /// 非法字节被替换为 '?',绝不抛异常。
+ ///
+ 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 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 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 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();
+ }
+ }
+
+ ///
+ /// 发送查询命令并读取设备回传的 IEEE 488.2 定长二进制块,自动剥离块头(#N<长度>),只返回纯数据字节。
+ /// 示波器 FILESystem:READFile 回传的 PNG 截图、CURVe? 波形等均以该块格式封装:
+ /// #9<9位长度><二进制数据><LF>。若把含 #9... 头的整段原始字节直接写入 .png,
+ /// 文件将不以 PNG 魔数(\x89PNG)开头而被判为损坏——故必须解析并丢弃块头,只落地纯数据。
+ /// 读取期间关闭终止符,避免数据中的 0x0A 被误判为结束;并按块头声明的长度精确读取,
+ /// 不再依赖 END 指示,从根本上规避大块传输的 IOTimeoutException。
+ ///
+ public async Task ReadAllBytesAsync(string queryCommand, CancellationToken ct = default)
+ {
+ await _commLock.WaitAsync(ct);
+ try
+ {
+ EnsureConnected();
+ return await Task.Run(() => ReadIEEEBlock(queryCommand), ct);
+ }
+ finally
+ {
+ _commLock.Release();
+ }
+ }
+
+ ///
+ /// 同步读取一个 IEEE 488.2 定长二进制块(#N<长度><数据><终止符>)并返回其纯数据部分。
+ ///
+ 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:启用终止符,读到换行为止
+ _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();
+
+ // 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;
+ }
+ }
+
+ /// 读取 1 个字节并返回其整数值(0-255);无数据则抛异常。
+ private int ReadOneByte()
+ {
+ byte[] b = _session!.RawIO.Read(1);
+ if (b == null || b.Length == 0)
+ throw new InvalidOperationException("读取 IEEE 块头时未收到任何字节。");
+ return b[0];
+ }
+
+ /// 跳过前导空白/换行后读取块头起始字节,返回其整数值。
+ private int ReadBlockStartByte()
+ {
+ int b = ReadOneByte();
+ for (int guard = 0; (b == '\n' || b == '\r' || b == ' ' || b == '\t') && guard < 64; guard++)
+ b = ReadOneByte();
+ return b;
+ }
+
+ /// 把首字节并回数组前部(用于设备直接回裸数据的退化路径)。
+ private static byte[] PrependByte(byte first, byte[]? rest)
+ {
+ rest ??= Array.Empty();
+ var result = new byte[rest.Length + 1];
+ result[0] = first;
+ Buffer.BlockCopy(rest, 0, result, 1, rest.Length);
+ return result;
+ }
+
+ ///
+ /// 循环读取设备回传的裸二进制数据,直到收到 END 指示或终止符为止(参考 ADP TCP “读到没有为止” 策略)。
+ /// 无参 RawIO.Read() 只返回首个内部缓冲(约 1KB)即结束,会把大文件(如 PNG 截图)截断;
+ /// 本方法改用 RawIO.Read(count, out ReadStatus) 分块读取,依据 判定结束:
+ /// EndReceived / TerminationCharacterEncountered 表示传输完成,
+ /// MaximumCountReached 表示本次读满、可能仍有数据需继续读取。
+ ///
+ /// 调用前的 I/O 超时(毫秒);大块传输期间在此放宽,读取结束后由调用方 finally 统一恢复。
+ /// 设备回传的完整裸二进制字节。
+ 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);
+ }
+ }
+}
diff --git a/DeviceCommand/DeviceCommand.csproj b/DeviceCommand/DeviceCommand.csproj
index 998d41b..813aecc 100644
--- a/DeviceCommand/DeviceCommand.csproj
+++ b/DeviceCommand/DeviceCommand.csproj
@@ -10,6 +10,7 @@
+
diff --git a/DeviceCommand/Devices/TektronixMSO.cs b/DeviceCommand/Devices/TektronixMSO.cs
index 0913bd5..0f81bc5 100644
--- a/DeviceCommand/Devices/TektronixMSO.cs
+++ b/DeviceCommand/Devices/TektronixMSO.cs
@@ -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
///
- /// Tektronix 示波器 (MSO 4/5/6 系列),支持采集控制、通道设置、自动测量与截图导出
+ /// Tektronix 示波器 (MSO 4/5/6 系列),支持采集控制、通道设置、自动测量与截图导出。
+ /// 传输层为 VISA(资源字符串寻址,如 TCPIP0::IP::inst0::INSTR),SCPI 指令集不变。
///
[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)
///
- /// 捕获示波器屏幕并保存到上位机本地路径
+ /// 捕获示波器当前屏幕并保存为 PNG 到上位机本地路径 (SAVe:IMAGe / FILESystem:READFile 指令)。
+ /// 执行流程:① SAVe:IMAGe 将屏幕截图写入示波器本地临时文件;② *OPC? 阻塞等待写盘完成;
+ /// ③ FILESystem:READFile 以 IEEE 488.2 定长块(#9<长度><数据>)回传文件,基类自动剥离块头后返回纯 PNG 字节;
+ /// ④ 写入上位机本地磁盘;⑤ 删除示波器临时文件,保持设备存储干净。
///
/// 保存到电脑本地的文件路径 (例如: @"C:\temp\screen.png")
/// 异步取消令牌
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);
}
- ///
- /// ⚠️ 请在这里将您的网络流对接起来!
- /// 依据您 Tcp 基类的设计,返回用于读取底层字节流的 Stream。
- ///
- protected virtual Stream GetNetworkStream()
- {
- // 【修改提示】:
- // 如果您的基类暴露了 TcpClient,请取消注释并使用这一行:
- // return this.TcpClient.GetStream();
-
- // 如果您的基类直接叫 _stream 或 Stream,请返回它:
- // return this.Stream;
-
- // 假如不知道怎么填,请查看 Tcp 类的源码。
- throw new NotImplementedException("请在此方法中返回基类的 NetworkStream 或 Socket 流对象。");
- }
-
- ///
- /// 辅助方法:读取 IEEE 488.2 标准的二进制块数据 (Block Data)
- ///
- protected virtual async Task 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 个字符 ,它代表接下来有多少个字符表示数据长度
- 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. 读取 所指示的长度字符,将其解析为整数 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 个字节 ...,这就是完整的图像数据
- 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. 结尾可能包含 (例如 LF \n),将其读取并丢弃以清空缓冲区
- try
- {
- await stream.ReadAsync(singleByte, 0, 1, ct);
- }
- catch { /* 忽略末尾符读取的可能超时 */ }
-
- return imageData;
- }
-
#endregion
#region 7. 状态与错误队列 (Status and Error)
diff --git a/DeviceEditModule/DeviceEditModule.cs b/DeviceEditModule/DeviceEditModule.cs
index 1e73203..bcb8949 100644
--- a/DeviceEditModule/DeviceEditModule.cs
+++ b/DeviceEditModule/DeviceEditModule.cs
@@ -23,6 +23,7 @@ namespace DeviceEditModule
containerRegistry.RegisterForNavigation("MCc30WView");
containerRegistry.RegisterForNavigation("RLT1000View");
containerRegistry.RegisterForNavigation("S7200View");
+ containerRegistry.RegisterForNavigation("TektronixMSOView");
containerRegistry.Register();
containerRegistry.Register();
containerRegistry.Register();
@@ -31,6 +32,7 @@ namespace DeviceEditModule
containerRegistry.Register();
containerRegistry.Register();
containerRegistry.Register();
+ containerRegistry.Register();
}
}
}
diff --git a/Model/Models/VisaConfig.cs b/Model/Models/VisaConfig.cs
new file mode 100644
index 0000000..50ec6d7
--- /dev/null
+++ b/Model/Models/VisaConfig.cs
@@ -0,0 +1,20 @@
+namespace Model.Models
+{
+ ///
+ /// VISA 通信参数(DeviceCommand 内部纯数据类,供设备类构造函数使用)。
+ /// 与 UIShare.UIViewModel.VisaConfigVM 字段一一对应,由 DeviceManager 在实例化时填充。
+ /// VISA 通过统一的资源字符串寻址,无需区分 IP/端口:
+ /// TCPIP0::192.168.20.125::inst0::INSTR、USB0::0x0699::0x0408::C012345::INSTR、GPIB0::7::INSTR 等。
+ ///
+ public class VisaConfig
+ {
+ /// VISA 资源字符串(由 NI-VISA 解析并自动选择对应的传输层)。
+ public string ResourceName { get; set; } = "TCPIP0::127.0.0.1::inst0::INSTR";
+
+ /// 打开 VISA 会话的最长等待时间(毫秒)。
+ public int OpenTimeout { get; set; } = 3000;
+
+ /// 读写 I/O 超时(毫秒),对应 VISA 的 TimeoutMilliseconds。
+ public int IOTimeout { get; set; } = 3000;
+ }
+}
diff --git a/SettingModule/SettingModule.cs b/SettingModule/SettingModule.cs
index a3f9aef..22d1492 100644
--- a/SettingModule/SettingModule.cs
+++ b/SettingModule/SettingModule.cs
@@ -19,6 +19,7 @@ namespace SettingModule
// 设备连接配置弹窗
containerRegistry.RegisterDialog("TCPConfig");
+ containerRegistry.RegisterDialog("VisaConfig");
containerRegistry.RegisterDialog("SerialPortConfig");
containerRegistry.RegisterDialog("CANConfig");
}
diff --git a/SettingModule/ViewModels/Dialogs/VisaConfigViewModel.cs b/SettingModule/ViewModels/Dialogs/VisaConfigViewModel.cs
new file mode 100644
index 0000000..228cb67
--- /dev/null
+++ b/SettingModule/ViewModels/Dialogs/VisaConfigViewModel.cs
@@ -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
+{
+ ///
+ /// VISA 连接配置对话框 VM。
+ /// 通过 DialogParameters 接收宿主 DeviceInfoVM;保存时把副本写回宿主。
+ ///
+ public class VisaConfigViewModel : DialogViewModelBase
+ {
+ #region 属性
+
+ private string _title = "VISA 连接配置";
+ public string Title
+ {
+ get => _title;
+ set => SetProperty(ref _title, value);
+ }
+
+ /// 编辑用的副本,取消时不会污染宿主对象。
+ private VisaConfigVM _config = new();
+ public VisaConfigVM Config
+ {
+ get => _config;
+ set => SetProperty(ref _config, value);
+ }
+
+ /// 常用超时(毫秒)。
+ public ObservableCollection CommonTimeouts { get; } = new()
+ {
+ 500, 1000, 2000, 3000, 5000, 10000
+ };
+
+ /// 常见 VISA 资源字符串模板,便于快速填写(可直接编辑)。
+ public ObservableCollection 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().Publish(true);
+
+ if (parameters.ContainsKey("Device"))
+ {
+ _hostDevice = parameters.GetValue("Device");
+ Title = $"VISA 连接配置 - {_hostDevice?.DeviceName}";
+ Config = new VisaConfigVM(_hostDevice?.VisaConfig);
+ }
+ else if (parameters.ContainsKey("Config"))
+ {
+ Config = new VisaConfigVM(parameters.GetValue("Config"));
+ }
+ }
+
+ public override void OnDialogClosed()
+ {
+ _eventAggregator.GetEvent().Publish(false);
+ }
+ #endregion
+ }
+}
diff --git a/SettingModule/ViewModels/SettingViewModel.cs b/SettingModule/ViewModels/SettingViewModel.cs
index 78ce317..0d312e2 100644
--- a/SettingModule/ViewModels/SettingViewModel.cs
+++ b/SettingModule/ViewModels/SettingViewModel.cs
@@ -59,7 +59,7 @@ namespace SettingModule.ViewModels
public ObservableCollection 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
diff --git a/SettingModule/Views/Dialogs/VisaConfigView.xaml b/SettingModule/Views/Dialogs/VisaConfigView.xaml
new file mode 100644
index 0000000..f7934ae
--- /dev/null
+++ b/SettingModule/Views/Dialogs/VisaConfigView.xaml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SettingModule/Views/Dialogs/VisaConfigView.xaml.cs b/SettingModule/Views/Dialogs/VisaConfigView.xaml.cs
new file mode 100644
index 0000000..a4a58c8
--- /dev/null
+++ b/SettingModule/Views/Dialogs/VisaConfigView.xaml.cs
@@ -0,0 +1,15 @@
+using System.Windows.Controls;
+
+namespace SettingModule.Views.Dialogs
+{
+ ///
+ /// VisaConfigView.xaml 的交互逻辑
+ ///
+ public partial class VisaConfigView : UserControl
+ {
+ public VisaConfigView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/UIShare/GlobalVariable/DeviceHealthMonitor.cs b/UIShare/GlobalVariable/DeviceHealthMonitor.cs
index b95d497..63d4d84 100644
--- a/UIShare/GlobalVariable/DeviceHealthMonitor.cs
+++ b/UIShare/GlobalVariable/DeviceHealthMonitor.cs
@@ -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
}
}
+ ///
+ /// 对 VISA 设备执行轻量级主动探活(SCPI *IDN?)。
+ /// 通过 WriteReadAsync 内部获取 _commLock,与监控采样天然串行化;
+ /// 死会话将在 VISA 自身的 IOTimeout 内抛出异常,返回 false 触发重连。
+ ///
+ /// true: 探活成功(会话确实存活); false: 探活失败(死会话)
+ private async Task 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;
+ }
+ }
+
///
/// 重连单个设备。
///
diff --git a/UIShare/GlobalVariable/DeviceManager.cs b/UIShare/GlobalVariable/DeviceManager.cs
index e585cfc..6d6ee3a 100644
--- a/UIShare/GlobalVariable/DeviceManager.cs
+++ b/UIShare/GlobalVariable/DeviceManager.cs
@@ -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 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 ConnectSerialAsync(string name, IBaseInterface device, CancellationToken ct)
{
if (device is not ISerialPort sp)
@@ -533,7 +551,34 @@ namespace UIShare.GlobalVariable
SendTimeout = vm.SendTimeout,
ReceiveTimeout = vm.ReceiveTimeout
};
- return Activator.CreateInstance(type, cfg) as IBaseInterface;
+ 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)
{
@@ -548,7 +593,15 @@ namespace UIShare.GlobalVariable
ReadTimeout = vm.ReadTimeout,
WriteTimeout = vm.WriteTimeout
};
- return Activator.CreateInstance(type, cfg) as IBaseInterface;
+ 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()
diff --git a/UIShare/UIViewModel/DeviceInfoVM.cs b/UIShare/UIViewModel/DeviceInfoVM.cs
index 441877a..e45ac51 100644
--- a/UIShare/UIViewModel/DeviceInfoVM.cs
+++ b/UIShare/UIViewModel/DeviceInfoVM.cs
@@ -61,6 +61,14 @@ namespace UIShare.UIViewModel
set => SetProperty(ref _tcpConfig, value);
}
+ /// VISA 连接参数(首次访问时自动初始化,便于 XAML 直接绑定)。
+ private VisaConfigVM _visaConfig = new();
+ public VisaConfigVM VisaConfig
+ {
+ get => _visaConfig;
+ set => SetProperty(ref _visaConfig, value);
+ }
+
/// 串口连接参数(首次访问时自动初始化,便于 XAML 直接绑定)。
private SerialPortConfigVM _serialPortConfig = new();
public SerialPortConfigVM SerialPortConfig
diff --git a/UIShare/UIViewModel/VisaConfigVM.cs b/UIShare/UIViewModel/VisaConfigVM.cs
new file mode 100644
index 0000000..0997234
--- /dev/null
+++ b/UIShare/UIViewModel/VisaConfigVM.cs
@@ -0,0 +1,56 @@
+using Prism.Mvvm;
+
+namespace UIShare.UIViewModel
+{
+ ///
+ /// VISA 连接配置(与 DeviceCommand.Base.Visa 保持字段一致)。
+ /// VISA 通过统一资源字符串寻址,无需区分 IP/端口,例如
+ /// TCPIP0::192.168.20.125::inst0::INSTR、USB0::0x0699::0x0408::C012345::INSTR。
+ ///
+ public class VisaConfigVM : BindableBase
+ {
+ private string _resourceName = "TCPIP0::127.0.0.1::inst0::INSTR";
+ /// VISA 资源字符串(TCPIP / USB / GPIB / ASRL 等统一寻址)。
+ public string ResourceName
+ {
+ get => _resourceName;
+ set => SetProperty(ref _resourceName, value);
+ }
+
+ private int _openTimeout = 3000;
+ /// 打开会话超时(毫秒)。
+ public int OpenTimeout
+ {
+ get => _openTimeout;
+ set => SetProperty(ref _openTimeout, value);
+ }
+
+ private int _ioTimeout = 3000;
+ /// 读写 I/O 超时(毫秒)。
+ public int IOTimeout
+ {
+ get => _ioTimeout;
+ set => SetProperty(ref _ioTimeout, value);
+ }
+
+ public VisaConfigVM() { }
+
+ /// 拷贝构造,用于对话框编辑副本。
+ public VisaConfigVM(VisaConfigVM? src)
+ {
+ if (src == null) return;
+ ResourceName = src.ResourceName;
+ OpenTimeout = src.OpenTimeout;
+ IOTimeout = src.IOTimeout;
+ }
+
+ /// 把字段拷回目标对象(保存时用)。
+ public void CopyTo(VisaConfigVM? dst)
+ {
+ if (dst == null) return;
+ dst.ResourceName = ResourceName;
+ dst.OpenTimeout = OpenTimeout;
+ dst.IOTimeout = IOTimeout;
+ }
+ }
+}