374 lines
14 KiB
Markdown
374 lines
14 KiB
Markdown
|
|
# IT8702P 电子负载驱动接入文档(C# / WPF / .NET 8)
|
|||
|
|
|
|||
|
|
> 本文档供 AI 开发助手作为实现依据使用。目标:在 C# + WPF + .NET 8 主项目中,
|
|||
|
|
> 通过 LAN Socket 接入 ITECH IT8702P 电子负载,采集**电压 / 电流 / 功率**三项数据,
|
|||
|
|
> 既能实时绑定到界面显示,也能由后台连续记录。
|
|||
|
|
>
|
|||
|
|
> **AI 开发约束**:严格遵循本文协议与命令格式。不要臆造未在本文出现的 SCPI 命令;
|
|||
|
|
> 命令一律以 `\n` 结尾;查询命令必须读完整行响应后再发下一条;设置类命令前必须已发送 `SYST:REM`。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. 通讯概览
|
|||
|
|
|
|||
|
|
| 项目 | 值 |
|
|||
|
|
|------|-----|
|
|||
|
|
| 物理接口 | LAN(以太网) |
|
|||
|
|
| 传输协议 | 标准 TCP(raw socket / SCPI-RAW),与普通 socket 编程完全一致 |
|
|||
|
|
| 默认 IP | `192.168.200.100`(以设备实际配置为准,需可在设置中修改) |
|
|||
|
|
| 端口 | `30000` |
|
|||
|
|
| 命令语言 | SCPI(ASCII 文本,大小写不敏感) |
|
|||
|
|
| 命令结束符 | `\n`(LF,0x0A) |
|
|||
|
|
| 响应结束符 | `\n`(按行读取) |
|
|||
|
|
| 编码 | ASCII |
|
|||
|
|
| 通道 | 主机带多负载模块;默认操作 `CH1`,可切换(`CHANnel <1-8>`) |
|
|||
|
|
|
|||
|
|
要点:
|
|||
|
|
|
|||
|
|
- 不需要 VISA、不需要厂商驱动 / SDK,`System.Net.Sockets.TcpClient` 直接连接即可。
|
|||
|
|
- **同步请求-应答模型**:每条查询命令发出后必须读回一行响应,才能发下一条;否则设备会报 `Query Interrupted`,且数据丢失。
|
|||
|
|
- 设备内部一次测量约几 ms 到十几 ms,单点轮询实际刷新率为每秒数十次量级,**不适合高速波形采集**(高速场景由独立采集卡负责,不在本驱动范围内)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. SCPI 命令清单(仅本项目所需)
|
|||
|
|
|
|||
|
|
### 2.1 连接与会话
|
|||
|
|
|
|||
|
|
| 命令 | 类型 | 说明 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| `*IDN?` | 查询 | 返回厂商/型号/序列号/固件版本,逗号分隔。用于连接自检 |
|
|||
|
|
| `SYST:REM` | 设置 | 进入远程模式。**任何设置类命令前必须先发**。只读测量不强制,但建议连接后即发 |
|
|||
|
|
| `SYST:LOC` | 设置 | 返回本地模式(前面板控制)。断开会话时可选发送 |
|
|||
|
|
| `SYST:ERR?` | 查询 | 读取错误队列下一条,格式 `<NR1>,<SRD>`,如 `0,"No Error"`。无错时返回 `0,No Error` |
|
|||
|
|
| `*CLS` | 设置 | 清除状态/错误队列 |
|
|||
|
|
|
|||
|
|
### 2.2 通道选择(多模块寻址)
|
|||
|
|
|
|||
|
|
| 命令 | 类型 | 说明 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| `CHAN <n>` | 设置 | 选择当前操作通道,`n` 取 1–8。默认 `CHAN 1`。后续命令作用于该通道 |
|
|||
|
|
| `CHAN?` | 查询 | 返回当前选中通道号 `<NR1>` |
|
|||
|
|
| `CHAN:ID?` | 查询 | 返回当前通道模块的型号/序列号/固件,逗号分隔 |
|
|||
|
|
|
|||
|
|
> 单通道使用时,连接后发一次 `CHAN 1` 即可;切换通道时重发 `CHAN <n>`。
|
|||
|
|
|
|||
|
|
### 2.3 测量(核心:电压 / 电流 / 功率)
|
|||
|
|
|
|||
|
|
| 命令 | 类型 | 返回 | 说明 |
|
|||
|
|
|------|------|------|------|
|
|||
|
|
| `MEAS:VOLT?` | 查询 | `<NR3>` | 触发一次新测量并返回电压(V)。较慢 |
|
|||
|
|
| `MEAS:CURR?` | 查询 | `<NR3>` | 触发一次新测量并返回电流(A) |
|
|||
|
|
| `MEAS:POW?` | 查询 | `<NR3>` | 触发一次新测量并返回功率(W) |
|
|||
|
|
| `FETC:VOLT?` | 查询 | `<NR3>` | 取最近一次采样的电压,不重新测量。较快 |
|
|||
|
|
| `FETC:CURR?` | 查询 | `<NR3>` | 取最近采样电流 |
|
|||
|
|
| `FETC:POW?` | 查询 | `<NR3>` | 取最近采样功率 |
|
|||
|
|
|
|||
|
|
返回值为 ASCII 科学计数字符串,如 `2.971700E+00`,用 `double.Parse(s, CultureInfo.InvariantCulture)` 解析。
|
|||
|
|
|
|||
|
|
> **`MEAS` vs `FETC`**:`MEAS` 每次强制重新测量,数值最新但慢;`FETC` 取设备已有采样,快但可能略滞后。连续轮询场景推荐 `FETC` 以提升刷新率。
|
|||
|
|
|
|||
|
|
### 2.4 合并查询(一次往返取三个值,强烈推荐)
|
|||
|
|
|
|||
|
|
SCPI 允许用分号在一条消息里串多个查询,**一次往返**拿回多值,往返次数从 3 次降到 1 次:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
MEAS:VOLT?;:MEAS:CURR?;:MEAS:POW?
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- 第二条及之后每条查询前加 `:`,表示回到命令根路径(否则会拼接出错误路径)。
|
|||
|
|
- 响应为分号分隔的一行,如 `2.971700E+00;1.040000E+00;3.090000E+00`,按 `;` 拆分解析。
|
|||
|
|
- `FETC` 同理可合并:`FETC:VOLT?;:FETC:CURR?;:FETC:POW?`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. 标准交互流程
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
[连接]
|
|||
|
|
TcpClient.Connect(ip, 30000)
|
|||
|
|
设置 TcpClient.NoDelay = true // 关闭 Nagle,消除小包延迟
|
|||
|
|
发送 *IDN? → 读回一行,校验包含 "IT87" 字样,确认设备正确
|
|||
|
|
发送 SYST:REM
|
|||
|
|
发送 CHAN 1
|
|||
|
|
[采集循环](按设定周期,如每 50~200ms 一次)
|
|||
|
|
发送 FETC:VOLT?;:FETC:CURR?;:FETC:POW?
|
|||
|
|
读回一行 → 按 ';' 拆分 → 解析为 (V, I, P)
|
|||
|
|
推送给 UI(绑定)与 后台记录器(队列)
|
|||
|
|
[断开]
|
|||
|
|
发送 SYST:LOC(可选)
|
|||
|
|
关闭 socket
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
错误处理约定:
|
|||
|
|
|
|||
|
|
- 每条查询发送后若在超时内(建议 1–3 s)未读到完整行 → 视为通讯异常,触发重连流程。
|
|||
|
|
- 周期性(或异常后)发送 `SYST:ERR?` 检查设备侧错误队列,非 `0,No Error` 则记录日志。
|
|||
|
|
- 读响应以 `\n` 为行边界;TCP 可能分包,需循环 `Read` 直到读到 `\n`。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. C# 实现指引(.NET 8)
|
|||
|
|
|
|||
|
|
### 4.1 分层结构建议
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Driver 层 It8702pClient —— 纯通讯:连接、收发、按行读取、命令封装
|
|||
|
|
采集层 AcquisitionService —— 后台采集循环(Task + PeriodicTimer),产出读数
|
|||
|
|
分发层 读数 → ① UI 绑定(INotifyPropertyChanged,Dispatcher 回主线程)
|
|||
|
|
→ ② 记录器(Channel<Reading> 写入文件/数据库,独立消费者)
|
|||
|
|
ViewModel 层 绑定 Voltage / Current / Power 与 IsConnected 等状态
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
设计要点(对应"实时绑定 + 后台记录两者都要"):
|
|||
|
|
|
|||
|
|
- **采集只跑一遍**,结果同时分发给 UI 和记录器,避免对设备发起两套独立轮询(同步协议下并发查询会互相打断)。
|
|||
|
|
- UI 更新必须切回 UI 线程:`Application.Current.Dispatcher.Invoke(...)` 或在采集 Task 中用 `IProgress<Reading>`。
|
|||
|
|
- 后台记录用 `System.Threading.Channels.Channel<Reading>`:采集线程只管入队(非阻塞),单独的写盘消费者出队,互不阻塞,避免磁盘 IO 拖慢采集节奏。
|
|||
|
|
- 所有 socket 收发集中在 Driver 层,**全程串行**(同一时刻只有一条命令在途)。如有多处调用,用 `SemaphoreSlim(1,1)` 串行化,杜绝 `Query Interrupted`。
|
|||
|
|
|
|||
|
|
### 4.2 数据模型
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
public readonly record struct Reading(
|
|||
|
|
DateTime TimestampUtc,
|
|||
|
|
int Channel,
|
|||
|
|
double Voltage, // V
|
|||
|
|
double Current, // A
|
|||
|
|
double Power); // W
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.3 Driver 层参考实现
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
using System.Globalization;
|
|||
|
|
using System.Net.Sockets;
|
|||
|
|
using System.Text;
|
|||
|
|
|
|||
|
|
public sealed class It8702pClient : IDisposable
|
|||
|
|
{
|
|||
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|||
|
|
private TcpClient? _tcp;
|
|||
|
|
private NetworkStream? _stream;
|
|||
|
|
|
|||
|
|
public bool IsConnected => _tcp?.Connected ?? false;
|
|||
|
|
|
|||
|
|
public async Task ConnectAsync(string ip, int port = 30000, int channel = 1,
|
|||
|
|
CancellationToken ct = default)
|
|||
|
|
{
|
|||
|
|
_tcp = new TcpClient { NoDelay = true }; // 关闭 Nagle
|
|||
|
|
await _tcp.ConnectAsync(ip, port, ct);
|
|||
|
|
_stream = _tcp.GetStream();
|
|||
|
|
_stream.ReadTimeout = 3000;
|
|||
|
|
_stream.WriteTimeout = 3000;
|
|||
|
|
|
|||
|
|
var idn = await QueryAsync("*IDN?", ct);
|
|||
|
|
if (!idn.Contains("IT87", StringComparison.OrdinalIgnoreCase))
|
|||
|
|
throw new InvalidOperationException($"意外的设备标识: {idn}");
|
|||
|
|
|
|||
|
|
await SendAsync("SYST:REM", ct); // 进入远程模式
|
|||
|
|
await SendAsync($"CHAN {channel}", ct); // 选择通道
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 一次往返采集 V/I/P。
|
|||
|
|
public async Task<(double V, double I, double P)> ReadVipAsync(CancellationToken ct = default)
|
|||
|
|
{
|
|||
|
|
// FETC 较快;如需每次强制重测可换成 MEAS
|
|||
|
|
var resp = await QueryAsync("FETC:VOLT?;:FETC:CURR?;:FETC:POW?", ct);
|
|||
|
|
var parts = resp.Split(';', StringSplitOptions.TrimEntries);
|
|||
|
|
if (parts.Length < 3)
|
|||
|
|
throw new FormatException($"响应格式异常: {resp}");
|
|||
|
|
return (ParseNum(parts[0]), ParseNum(parts[1]), ParseNum(parts[2]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public Task SelectChannelAsync(int channel, CancellationToken ct = default)
|
|||
|
|
=> SendAsync($"CHAN {channel}", ct);
|
|||
|
|
|
|||
|
|
public async Task<string> ReadErrorAsync(CancellationToken ct = default)
|
|||
|
|
=> await QueryAsync("SYST:ERR?", ct);
|
|||
|
|
|
|||
|
|
// ---- 底层收发(全程串行)----
|
|||
|
|
|
|||
|
|
private async Task SendAsync(string cmd, CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
await _gate.WaitAsync(ct);
|
|||
|
|
try { await WriteLineAsync(cmd, ct); }
|
|||
|
|
finally { _gate.Release(); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async Task<string> QueryAsync(string cmd, CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
await _gate.WaitAsync(ct);
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
await WriteLineAsync(cmd, ct);
|
|||
|
|
return await ReadLineAsync(ct);
|
|||
|
|
}
|
|||
|
|
finally { _gate.Release(); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async Task WriteLineAsync(string cmd, CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
var bytes = Encoding.ASCII.GetBytes(cmd + "\n");
|
|||
|
|
await _stream!.WriteAsync(bytes, ct);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TCP 可能分包,循环读到 '\n' 为止
|
|||
|
|
private async Task<string> ReadLineAsync(CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
var sb = new StringBuilder();
|
|||
|
|
var buf = new byte[1];
|
|||
|
|
while (true)
|
|||
|
|
{
|
|||
|
|
int n = await _stream!.ReadAsync(buf, ct);
|
|||
|
|
if (n == 0) throw new IOException("连接已关闭");
|
|||
|
|
char c = (char)buf[0];
|
|||
|
|
if (c == '\n') break;
|
|||
|
|
if (c != '\r') sb.Append(c);
|
|||
|
|
}
|
|||
|
|
return sb.ToString().Trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static double ParseNum(string s)
|
|||
|
|
=> double.Parse(s, NumberStyles.Float, CultureInfo.InvariantCulture);
|
|||
|
|
|
|||
|
|
public void Dispose()
|
|||
|
|
{
|
|||
|
|
try { _stream?.Dispose(); _tcp?.Dispose(); } catch { /* 忽略 */ }
|
|||
|
|
_gate.Dispose();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.4 采集服务参考实现
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
using System.Threading.Channels;
|
|||
|
|
|
|||
|
|
public sealed class AcquisitionService : IAsyncDisposable
|
|||
|
|
{
|
|||
|
|
private readonly It8702pClient _client;
|
|||
|
|
private readonly Channel<Reading> _record = Channel.CreateUnbounded<Reading>();
|
|||
|
|
private CancellationTokenSource? _cts;
|
|||
|
|
private Task? _loop;
|
|||
|
|
|
|||
|
|
public AcquisitionService(It8702pClient client) => _client = client;
|
|||
|
|
|
|||
|
|
/// 供后台记录器消费的读数流。
|
|||
|
|
public ChannelReader<Reading> Records => _record.Reader;
|
|||
|
|
|
|||
|
|
/// UI 通过此回调实时获取读数(在 ViewModel 中切回 UI 线程)。
|
|||
|
|
public event Action<Reading>? ReadingReceived;
|
|||
|
|
|
|||
|
|
public void Start(int channel, TimeSpan period)
|
|||
|
|
{
|
|||
|
|
_cts = new CancellationTokenSource();
|
|||
|
|
_loop = RunAsync(channel, period, _cts.Token);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async Task RunAsync(int channel, TimeSpan period, CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
using var timer = new PeriodicTimer(period);
|
|||
|
|
while (await timer.WaitForNextTickAsync(ct))
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
var (v, i, p) = await _client.ReadVipAsync(ct);
|
|||
|
|
var reading = new Reading(DateTime.UtcNow, channel, v, i, p);
|
|||
|
|
|
|||
|
|
ReadingReceived?.Invoke(reading); // → UI
|
|||
|
|
_record.Writer.TryWrite(reading); // → 记录器(非阻塞入队)
|
|||
|
|
}
|
|||
|
|
catch (OperationCanceledException) { break; }
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
// TODO: 记录日志 / 触发重连
|
|||
|
|
System.Diagnostics.Debug.WriteLine($"采集异常: {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public async ValueTask DisposeAsync()
|
|||
|
|
{
|
|||
|
|
_cts?.Cancel();
|
|||
|
|
if (_loop is not null)
|
|||
|
|
try { await _loop; } catch { /* 忽略 */ }
|
|||
|
|
_record.Writer.TryComplete();
|
|||
|
|
_cts?.Dispose();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.5 ViewModel 绑定要点
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
public sealed class MainViewModel : INotifyPropertyChanged
|
|||
|
|
{
|
|||
|
|
private double _voltage, _current, _power;
|
|||
|
|
public double Voltage { get => _voltage; private set => Set(ref _voltage, value); }
|
|||
|
|
public double Current { get => _current; private set => Set(ref _current, value); }
|
|||
|
|
public double Power { get => _power; private set => Set(ref _power, value); }
|
|||
|
|
|
|||
|
|
// 订阅 AcquisitionService.ReadingReceived,并切回 UI 线程:
|
|||
|
|
private void OnReading(Reading r) =>
|
|||
|
|
Application.Current.Dispatcher.Invoke(() =>
|
|||
|
|
{
|
|||
|
|
Voltage = r.Voltage;
|
|||
|
|
Current = r.Current;
|
|||
|
|
Power = r.Power;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ... INotifyPropertyChanged / Set 实现略
|
|||
|
|
public event PropertyChangedEventHandler? PropertyChanged;
|
|||
|
|
private void Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
|||
|
|
{ field = value; PropertyChanged?.Invoke(this, new(name)); }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
后台记录消费者(独立运行,写文件/数据库):
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
await foreach (var r in acquisition.Records.ReadAllAsync(ct))
|
|||
|
|
{
|
|||
|
|
// 写 CSV / SQLite / 其他。IO 慢也不会阻塞采集循环
|
|||
|
|
await writer.WriteLineAsync(
|
|||
|
|
$"{r.TimestampUtc:O},{r.Channel},{r.Voltage},{r.Current},{r.Power}");
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. 配置项(建议放入主项目设置)
|
|||
|
|
|
|||
|
|
| 配置 | 默认值 | 说明 |
|
|||
|
|
|------|--------|------|
|
|||
|
|
| `Ip` | `192.168.200.100` | 设备 IP,可改 |
|
|||
|
|
| `Port` | `30000` | 固定,一般不改 |
|
|||
|
|
| `Channel` | `1` | 操作通道,可切换 1–8 |
|
|||
|
|
| `PeriodMs` | `100` | 采集周期(毫秒)。过小无意义,受设备测量速度限制 |
|
|||
|
|
| `UseFetch` | `true` | `true` 用 `FETC`(快),`false` 用 `MEAS`(每次重测) |
|
|||
|
|
| `TimeoutMs` | `3000` | 收发超时 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. 易错点检查清单(AI 实现时务必核对)
|
|||
|
|
|
|||
|
|
- [ ] 每条命令结尾加 `\n`。
|
|||
|
|
- [ ] 设置类命令前已发送 `SYST:REM`。
|
|||
|
|
- [ ] 查询发出后**读完整行响应再发下一条**;所有收发经 `SemaphoreSlim` 串行化。
|
|||
|
|
- [ ] 合并查询时第二条起每条前加 `:`(回根路径)。
|
|||
|
|
- [ ] 数字解析用 `CultureInfo.InvariantCulture`(避免逗号小数点区域问题)。
|
|||
|
|
- [ ] 读响应循环到 `\n`,处理 TCP 分包。
|
|||
|
|
- [ ] `TcpClient.NoDelay = true`。
|
|||
|
|
- [ ] UI 更新切回 Dispatcher 线程。
|
|||
|
|
- [ ] 采集只一套循环,结果分发给 UI 与记录器,不开两套独立轮询。
|
|||
|
|
- [ ] 不要在本驱动里追求高速波形——高速由独立采集卡负责。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. 范围说明
|
|||
|
|
|
|||
|
|
本驱动只覆盖**连接、通道选择、V/I/P 采集**及配套的错误读取。
|
|||
|
|
若后续需要控制负载(CC/CV/CR/CW 设置、输入开关、保护读取、List/动态测试等),
|
|||
|
|
另需扩展命令集,不在本文档范围内。高速暂态波形采集由独立采集卡完成,与本驱动并行运行、互不干扰。
|