SSPC-Tester/Driver/IT8702P/IT8702P_Driver_Integration_Guide.md
2026-06-30 12:10:29 +08:00

14 KiB
Raw Blame 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 文本,大小写不敏感)
命令结束符 \nLF0x0A
响应结束符 \n(按行读取)
编码 ASCII
通道 主机带多负载模块;默认操作 CH1,可切换(CHANnel <1-8>

要点:

  • 不需要 VISA、不需要厂商驱动 / SDKSystem.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 FETCMEAS 每次强制重新测量,数值最新但慢;FETC 取设备已有采样,快但可能略滞后。连续轮询场景推荐 FETC 以提升刷新率。

2.4 合并查询(一次往返取三个值,强烈推荐)

SCPI 允许用分号在一条消息里串多个查询,一次往返拿回多值,往返次数从 3 次降到 1 次:

MEAS:VOLT?;:MEAS:CURR?;:MEAS:POW?
  • 第二条及之后每条查询前加 :,表示回到命令根路径(否则会拼接出错误路径)。
  • 响应为分号分隔的一行,如 2.971700E+00;1.040000E+00;3.090000E+00,按 ; 拆分解析。
  • FETC 同理可合并:FETC:VOLT?;:FETC:CURR?;:FETC:POW?

3. 标准交互流程

[连接]
  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 数据模型

public readonly record struct Reading(
    DateTime TimestampUtc,
    int Channel,
    double Voltage,   // V
    double Current,   // A
    double Power);    // W

4.3 Driver 层参考实现

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 采集服务参考实现

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 绑定要点

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)); }
}

后台记录消费者(独立运行,写文件/数据库):

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 trueFETC(快),falseMEAS(每次重测)
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/动态测试等), 另需扩展命令集,不在本文档范围内。高速暂态波形采集由独立采集卡完成,与本驱动并行运行、互不干扰。