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 @@ + + +