SSPC-Tester/Driver/IT8702P/IT8702P_Driver_Integration_Guide.md

374 lines
14 KiB
Markdown
Raw Permalink Normal View History

# IT8702P 电子负载驱动接入文档C# / WPF / .NET 8
> 本文档供 AI 开发助手作为实现依据使用。目标:在 C# + WPF + .NET 8 主项目中,
> 通过 LAN Socket 接入 ITECH IT8702P 电子负载,采集**电压 / 电流 / 功率**三项数据,
> 既能实时绑定到界面显示,也能由后台连续记录。
>
> **AI 开发约束**:严格遵循本文协议与命令格式。不要臆造未在本文出现的 SCPI 命令;
> 命令一律以 `\n` 结尾;查询命令必须读完整行响应后再发下一条;设置类命令前必须已发送 `SYST:REM`。
---
## 1. 通讯概览
| 项目 | 值 |
|------|-----|
| 物理接口 | LAN以太网 |
| 传输协议 | 标准 TCPraw socket / SCPI-RAW与普通 socket 编程完全一致 |
| 默认 IP | `192.168.200.100`(以设备实际配置为准,需可在设置中修改) |
| 端口 | `30000` |
| 命令语言 | SCPIASCII 文本,大小写不敏感) |
| 命令结束符 | `\n`LF0x0A |
| 响应结束符 | `\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` 取 18。默认 `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
```
错误处理约定:
- 每条查询发送后若在超时内(建议 13 s未读到完整行 → 视为通讯异常,触发重连流程。
- 周期性(或异常后)发送 `SYST:ERR?` 检查设备侧错误队列,非 `0,No Error` 则记录日志。
- 读响应以 `\n` 为行边界TCP 可能分包,需循环 `Read` 直到读到 `\n`
---
## 4. C# 实现指引(.NET 8
### 4.1 分层结构建议
```
Driver 层 It8702pClient —— 纯通讯:连接、收发、按行读取、命令封装
采集层 AcquisitionService —— 后台采集循环Task + PeriodicTimer产出读数
分发层 读数 → ① UI 绑定INotifyPropertyChangedDispatcher 回主线程)
→ ② 记录器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` | 操作通道,可切换 18 |
| `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/动态测试等),
另需扩展命令集,不在本文档范围内。高速暂态波形采集由独立采集卡完成,与本驱动并行运行、互不干扰。