鸿蒙PC平台数采工作台适配实战:基于 Electron 壳方案的 SCPI 会话与 Python 脚本生成引擎开发
数采工作台是一款面向仪器控制与数据采集的纯 JavaScript 桌面应用,提供 SCPI 命令语法校验(IEEE 488.2 / SCPI-99)、VISA 资源字符串解析(TCPIP / USB / GPIB / ASRL 全协议族)、DAQmx 通道配置校验、Python 脚本生成(PyVISA 会话回放 / PyDAQmx 采集 / VISA 资源扫描)、SCPI 交互式控制台、会话文件存取、示例场景管理等完整仪器工程能力。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个具备仪器通信协议校验、多类型脚本生成、会话持久化、以及 Catppuccin Mocha 深色主题的完整过程——涵盖 SCPI 命令解析引擎、VISA 资源正则匹配、DAQmx 物理通道命名校验、Python 代码生成器、dialog + fs 文件操作 IPC、grid 同格叠加弹层、以及鸿蒙安全适配等核心功能的实现细节与踩坑经验。
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_PyVISA_PyDAQmx
一、技术架构分析
1.1 整体架构
本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程的分层架构:
- Electron Web 层:运行于 ArkWeb 引擎,提供完整的 SCPI 命令校验、VISA 资源解析、DAQmx 配置校验、Python 脚本生成、交互式控制台、会话文件管理与示例场景等仪器控制能力
- 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、原生对话框、屏幕自适应等系统能力
这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。与采用 contextBridge 安全模型的 Electron 应用不同,本工程采用 nodeIntegration:true + contextIsolation:false 的直通模式——渲染进程直接访问 Node.js 的 fs、electron 的 ipcRenderer 等 API,主进程仅负责 dialog 对话框与文件读写 IPC,引擎逻辑全部在渲染进程内完成。
1.2 技术方案
| 对比项 | 传统仪器软件(LabVIEW / NI MAX) | 鸿蒙适配版(数采工作台) |
|---|---|---|
| 协议支持 | 完整 VISA + 厂商驱动 | SCPI 语法校验 + VISA 资源解析(纯前端) |
| 脚本生成 | 图形化编程 | Python 代码生成器(PyVISA / PyDAQmx) |
| 控制台 | 硬件面板 | SCPI 交互式控制台 + 快捷命令按钮 |
| 配置校验 | 依赖硬件连接 | DAQmx 通道/采样/量程纯软件校验 |
| 会话管理 | 项目文件 | JSON 会话文件存取 + 示例场景预设 |
| 部署方式 | 安装包 | HAP 包(鸿蒙应用) |
| 运行环境 | Windows/Linux + NI 驱动 | 鸿蒙 PC(ArkWeb 引擎) |
1.3 核心功能清单
| 功能模块 | 具体能力 |
|---|---|
| SCPI 校验 | IEEE 488.2 通用命令表(16 条)+ SCPI-99 助记符语法分析 |
| 参数分析 | 5 种参数类型识别(string / number / number+unit / bool / other) |
| VISA 解析 | TCPIP(VXI-11 / HiSLIP / SOCKET)+ USB(USBTMC)+ GPIB + ASRL |
| DAQmx 校验 | 通道命名正则(ai / ao / dio)+ 采样率/采样数/量程/接线端配置 |
| 脚本生成 | PyVISA 会话回放 + PyDAQmx 采集 + VISA 资源扫描(3 类脚本) |
| SCPI 控制台 | 命令输入/回车发送 + 7 个快捷按钮 + 命令计数 + 语法校验反馈 |
| 会话管理 | JSON 文件存取 + 新建/打开/保存 + 状态栏文件路径显示 |
| 示例场景 | 5 个预设(DMM/示波器/函数发生器/串口仪器/DAQmx 采集) |
| 标签页切换 | SCPI 控制台 / 脚本预览双标签页 |
| 文件操作 IPC | 5 个通道(openSession / read / saveSessionDialog / saveScriptDialog / write) |
| 弹层系统 | grid 同格叠加 + .open 类显隐切换(关于 / 示例两个弹层) |
| 鸿蒙安全 | 菜单双保险 + 导航拦截 + 新窗口拦截 + 禁用硬件加速 |
| 深色主题 | Catppuccin Mocha 配色 + CSS 变量体系 + 表单/按钮/控制台样式 |
二、环境准备



2.1 开发环境要求
| 项目 | 版本/信息 |
|---|---|
| 操作系统 | Windows 10/11 |
| 核心框架 | Electron (Node.js + Chromium) |
| 技术栈 | HTML5/Vanilla JavaScript |
| 引擎架构 | 渲染进程直通 Node.js API |
| 目标设备 | 鸿蒙 PC |
| 目标架构 | arm64-v8a |
| 开发工具 | DevEco Studio(鸿蒙官方 IDE) |
2.2 项目结构
ohos_hap/
├── electron/ # Electron 壳
├── web_engine/ # 鸿蒙壳工程
│ └── src/main/resources/resfile/resources/app/ # 部署目录(业务代码平铺)
│ ├── main.js # 主进程(窗口管理 + 文件操作 IPC)
│ ├── renderer.js # 渲染进程(UI 交互 + SCPI 会话 + 脚本生成)
│ ├── pyvisadaq-engine.js # 引擎层(SCPI 校验 + VISA 解析 + DAQmx + 代码生成)
│ ├── index.html # 页面布局(工具栏 + 左侧面板 + 标签页工作区 + 弹层)
│ ├── package.json # 项目配置
│ └── styles/
│ └── pyvisadaq.css # 深色主题样式(Catppuccin Mocha + grid 弹层)
└── AppScope/ # 应用配置(bundleName: com.wei.shucai)
2.3 依赖说明
| 依赖 | 版本 | 用途 |
|---|---|---|
| electron | 系统内置 | 桌面应用框架 |
| pyvisa | 参考源码 | VISA 资源名解析规则 / 高层 API |
| pyvisa-py | 参考源码 | TCPIP 会话分发(VXI-11 / HiSLIP) |
| PyDAQmx | 参考源码 | 双命名机制与 Task RAII 生命周期 |
2.4 应用身份信息
| 字段 | 值 | 说明 |
|---|---|---|
| bundleName | com.wei.shucai | 应用唯一标识 |
| vendor | wei | 开发者 |
| versionName | 1.0.0 | 应用版本 |
| 应用名称 | 数采工作台 | 中文显示名 |
| 英文名 | shucai | package.json name |
| 许可证 | Apache-2.0 | 开源协议 |
2.5 构建与部署
构建流程遵循鸿蒙 HAP 壳工程的标准步骤:
- 在 DevEco Studio 中打开 ohos_hap 工程
- 确保 web_engine/src/main/resources/resfile/resources/app/ 部署目录包含所有业务文件
- 选择 arm64-v8a 目标架构,构建 HAP 包
- 安装到鸿蒙 PC 设备运行
2.6 部署目录同步
每次修改 Electron 层代码后,需将文件同步到部署目录:
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron-apps/PyVISADAQ/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
2.7 鸿蒙权限配置
module.json5 中根据应用实际需要声明权限。数采工作台作为仪器控制工具,主要使用文件系统相关权限用于会话文件存取。
三、核心适配流程
3.1 主进程:窗口创建与安全配置
主进程负责窗口管理和文件操作 IPC,采用 nodeIntegration:true 直通模式:
// main.js —— 窗口创建与安全配置
var electron = require('electron');
var app = electron.app;
var Menu = electron.Menu;
var BrowserWindow = electron.BrowserWindow;
var screen = electron.screen;
var ipcMain = electron.ipcMain;
var dialog = electron.dialog;
var fs = require('fs');
app.disableHardwareAcceleration();
Menu.setApplicationMenu(null);
var mainWindow = null;
function createWindow() {
try {
var display = screen.getPrimaryDisplay();
mainWindow = new BrowserWindow({
width: Math.floor(display.workAreaSize.width * 0.95),
height: Math.floor(display.workAreaSize.height * 0.90),
frame: true,
transparent: false,
resizable: true,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
mainWindow.loadFile('index.html');
mainWindow.setMenu(null);
mainWindow.webContents.setWindowOpenHandler(function() { return { action: 'deny' }; });
mainWindow.webContents.on('will-navigate', function(e) { e.preventDefault(); });
} catch (e) {
console.warn('[数采] 创建窗口失败:', e.message);
}
}
app.whenReady().then(function() { createWindow(); });
app.on('window-all-closed', function() { app.quit(); });
关键配置说明:
- disableHardwareAcceleration:禁用硬件加速,避免鸿蒙 GPU 兼容性问题
- 菜单双保险:Menu.setApplicationMenu(null) + mainWindow.setMenu(null)
- 窗口尺寸:主显示器 workAreaSize 的 95% 宽 × 90% 高
- 导航拦截:setWindowOpenHandler 拒绝新窗口 + will-navigate 阻止外部导航
- 安全模型:nodeIntegration:true + contextIsolation:false,渲染进程直接访问 Node.js API
3.2 文件操作 IPC:5 个通道
主进程提供 5 个 IPC 通道处理文件操作:
// main.js —— 文件操作 IPC
ipcMain.handle('file:openSession', function() {
return dialog.showOpenDialog(mainWindow, {
title: '打开会话文件',
filters: [
{ name: '会话文件', extensions: ['json'] },
{ name: '所有文件', extensions: ['*'] }
],
properties: ['openFile']
});
});
ipcMain.handle('file:read', function(event, filePath) {
try {
var content = fs.readFileSync(filePath, 'utf-8');
return { success: true, content: content };
} catch (e) {
return { success: false, error: e.message };
}
});
ipcMain.handle('file:saveSessionDialog', function() {
return dialog.showSaveDialog(mainWindow, {
title: '保存会话',
defaultPath: 'session.json',
filters: [{ name: '会话文件', extensions: ['json'] }]
});
});
ipcMain.handle('file:saveScriptDialog', function(event, opts) {
opts = opts || {};
return dialog.showSaveDialog(mainWindow, {
title: opts.title || '保存脚本',
defaultPath: opts.defaultName || 'instrument_session.py',
filters: [
{ name: opts.filterName || 'Python 脚本', extensions: opts.filterExt ? [opts.filterExt] : ['py'] },
{ name: '所有文件', extensions: ['*'] }
]
});
});
ipcMain.handle('file:write', function(event, filePath, content) {
try {
fs.writeFileSync(filePath, content, 'utf-8');
return { success: true, size: content.length };
} catch (e) {
return { success: false, error: e.message };
}
});
设计要点:
- saveScriptDialog 参数化:通过 opts 传入 defaultName / filterName / filterExt,无参时回退到 Python 脚本默认值
- 统一返回格式:{ success, content/size } 或 { success: false, error }
- 文件过滤器:会话文件限定 .json 扩展名,脚本文件限定 .py 扩展名
四、SCPI 命令校验引擎
4.1 IEEE 488.2 通用命令表
SCPI(Standard Commands for Programmable Instruments)是仪器控制的通用语言标准。引擎首先定义了 16 条 IEEE 488.2 通用命令(星号命令):
// pyvisadaq-engine.js —— IEEE 488.2 通用命令表
var IEEE488_COMMON = {
'*IDN': '标识查询(厂商/型号/序列号/版本)',
'*RST': '复位设备到已知状态',
'*CLS': '清除标准事件状态与状态字节',
'*OPC': '操作完成(*OPC 完成后置位;*OPC? 阻塞直至完成)',
'*WAI': '等待所有未决操作完成',
'*ESR': '标准事件状态寄存器(*ESR? 查询并清除)',
'*STB': '状态字节(*STB? 只读查询)',
'*SRE': '服务请求使能寄存器',
'*ESE': '标准事件使能寄存器',
'*TST': '自检(*TST? 查询结果,0=通过)',
'*RCL': '调用已保存的仪器配置',
'*SAV': '保存当前仪器配置',
'*TRG': '触发(等同总线组触发)',
'*OPT': '选件查询(*OPT?)',
'*PSC': '上电状态清除开关',
'*LRN': '学习配置(厂商私有常见)'
};
该表覆盖仪器标识查询、状态清除、操作完成同步、自检等核心功能。validateScpi 函数在检测到星号前缀命令时直接查表返回描述。
4.2 参数类型分析
SCPI-99 标准定义了 5 种参数形态,引擎通过正则逐一匹配:
// pyvisadaq-engine.js —— 参数类型分析
function analyzeScpiParam(p) {
if (/^".*"$/.test(p)) { return 'string'; }
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(p)) { return 'number'; }
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?\s*[a-zA-Z%]+$/.test(p)) { return 'number+unit'; }
if (/^(ON|OFF|1|0)$/i.test(p)) { return 'bool'; }
return 'other';
}
匹配顺序:引号包裹的字符串 → 纯数值 → 带单位数值(如 10V、1KHZ)→ 布尔值(ON/OFF/1/0)→ 其他枚举。number+unit 的正则在纯数值基础上追加 [a-zA-Z%]+ 后缀,确保 1KHZ 被识别为带单位数值而非 other。
4.3 SCPI 语法校验流程
validateScpi 函数实现完整的 SCPI-99 语法分析:
// pyvisadaq-engine.js —— SCPI 语法校验
function validateScpi(cmd) {
var result = {
valid: false, isQuery: false, isCommon: false, isKnown: false,
commonDesc: '', header: '', params: [], paramTypes: [], error: ''
};
if (typeof cmd !== 'string') { result.error = '命令必须是字符串'; return result; }
var trimmed = cmd.trim();
if (trimmed.length === 0) { result.error = '命令为空'; return result; }
// 分离头部与参数(先按空格切分,查询标记附着在头部末尾助记符后)
var spaceIdx = trimmed.indexOf(' ');
var headerPart = spaceIdx === -1 ? trimmed : trimmed.substring(0, spaceIdx);
var paramPart = spaceIdx === -1 ? '' : trimmed.substring(spaceIdx + 1).trim();
// 提取查询标记
if (headerPart.charAt(headerPart.length - 1) === '?') {
result.isQuery = true;
headerPart = headerPart.slice(0, -1);
}
// 参数解析(按逗号分割,引号内不分割)
if (paramPart.length > 0) {
var params = [];
var current = '';
var inQuote = false;
for (var j = 0; j < paramPart.length; j++) {
var ch = paramPart.charAt(j);
if (ch === '"') { inQuote = !inQuote; current += ch; }
else if (ch === ',' && !inQuote) { params.push(current.trim()); current = ''; }
else { current += ch; }
}
params.push(current.trim());
result.params = params;
result.paramTypes = params.map(analyzeScpiParam);
}
// 通用命令(*IDN? 等)
if (headerPart.charAt(0) === '*') {
result.isCommon = true;
var commonMatch = headerPart.match(/^\*[A-Z:]+$/);
if (!commonMatch) {
result.error = '通用命令格式错误:应为 * 后跟大写字母/冒号(如 *IDN?)';
return result;
}
result.header = headerPart;
result.valid = true;
if (IEEE488_COMMON[headerPart]) {
result.isKnown = true;
result.commonDesc = IEEE488_COMMON[headerPart];
}
return result;
}
// SCPI-99 绝对路径:允许一个前导冒号(如 :MEAS:VPP? 表示从根节点开始的完整路径)
if (headerPart.charAt(0) === ':') { headerPart = headerPart.slice(1); }
// 标准命令:助记符必须为大写字母/数字/下划线,用冒号分隔
var mnemonics = headerPart.split(':');
for (var i = 0; i < mnemonics.length; i++) {
var m = mnemonics[i].trim();
if (m.length === 0) { result.error = '助记符为空(检查冒号位置)'; return result; }
if (!/^[A-Z][A-Z0-9_]*$/.test(m)) {
result.error = '助记符 "' + m + '" 格式错误:必须以大写字母开头,仅含大写字母/数字/下语符';
return result;
}
}
result.header = headerPart;
result.valid = true;
return result;
}
关键设计决策:
- 前导冒号处理:SCPI-99 允许绝对路径命令(如 :MEAS:VPP?),真实示波器/信号源大量使用,校验时剥离前导冒号避免误判
- 引号内逗号保护:参数解析阶段遇到引号内逗号不分割,确保 CONF:VOLT:DC 10, 0.001 正确解析为两个参数
- 助记符严格校验:必须以大写字母开头,仅含大写字母/数字/下划线,小写字母被拒绝
五、VISA 资源解析与 DAQmx 配置校验
5.1 VISA 资源字符串解析
VISA(Virtual Instrument Software Architecture)定义了统一的仪器资源命名规范。parseVisaResource 函数基于 pyvisa rname 模块的解析规则,支持 5 种资源类型:
// pyvisadaq-engine.js —— VISA 资源解析(TCPIP 分支)
m = /^TCPIP(\d+)?::(.+)$/.exec(res);
if (m) {
out.interface = 'TCPIP';
out.board = m[1] || '0';
var tparts = m[2].split('::');
var tlast = tparts[tparts.length - 1];
if (tlast === 'SOCKET') {
if (tparts.length < 3) {
out.error = 'SOCKET 资源缺少端口号(格式:TCPIP[board]::host::port::SOCKET)';
return out;
}
var port = tparts[tparts.length - 2];
if (!/^\d+$/.test(port) || parseInt(port, 10) < 1 || parseInt(port, 10) > 65535) {
out.error = 'SOCKET 端口号无效:' + port + '(应为 1-65535)';
return out;
}
out.valid = true;
out.resourceClass = 'SOCKET';
out.protocol = '原始 TCP Socket(对应 pyvisa-py TCPIPSocketSession)';
out.target = tparts[0] + ':' + port;
return out;
}
if (tlast === 'INSTR') { tparts.pop(); }
var host = tparts[0] || '';
if (!host) { out.error = 'TCPIP 资源缺少主机地址'; return out; }
var lanDevice = tparts[1] || 'inst0';
out.valid = true;
out.resourceClass = 'INSTR';
out.target = host;
if (/^hislip/i.test(lanDevice)) {
out.protocol = 'HiSLIP(lan_device_name = ' + lanDevice + ')';
} else {
out.protocol = 'VXI-11(pyvisa-py 对 INSTR 的默认分发,lan_device_name = ' + lanDevice + ')';
}
if (tparts.length > 2) {
out.warning = 'TCPIP INSTR 的多余段已忽略:' + tparts.slice(2).join('::');
}
return out;
}
TCPIP 分支的处理逻辑:先按 :: 分割,末段为 SOCKET 则走原始 TCP 套接字路径(校验端口号 1-65535),末段为 INSTR 或省略则走 VXI-11/HiSLIP 路径(按 lan_device_name 是否以 hislip 开头区分协议)。
其他资源类型的解析要点:
| 资源类型 | 正则模式 | 关键校验 |
|---|---|---|
| ASRL 板载串口 | ASRL(\d+)?(::INSTR)? | 默认 board=0,resourceClass=INSTR |
| ASRL 路径形式 | ASRL(/dev/[\w/.-]+)(::INSTR)? | Linux 设备路径直接通过 |
| COM 别名 | COM(\d+) | 提示等价 ASRL 写法,标记 warning |
| USB | USB(\d+)?::vid::pid[::serial][::iface]::INSTR | 必须以 ::INSTR 结尾,厂商 ID 通常 0x 前缀十六进制 |
| GPIB | GPIB(\d+)?::主地址[::次地址][::INSTR] | 主/次地址范围 0-30 |
| SERIAL | SERIAL.* | 判为无效,提示改用 ASRL |
5.2 DAQmx 通道校验
DAQmx 是 NI 的数据采集驱动,通道命名遵循严格的物理命名规范:
// pyvisadaq-engine.js —— DAQmx 通道校验
function validateDaqChannel(channel) {
var aiRe = /^[A-Za-z0-9]+\/ai\d+(:\d+)?$/;
var aoRe = /^[A-Za-z0-9]+\/ao\d+(:\d+)?$/;
var dioRe = /^[A-Za-z0-9]+\/port\d+\/line\d+(:\d+)?$/;
if (aiRe.test(channel)) { return { valid: true, kind: 'ai' }; }
if (aoRe.test(channel)) { return { valid: true, kind: 'ao' }; }
if (dioRe.test(channel)) { return { valid: true, kind: 'dio' }; }
return { valid: false, kind: '', error: '通道名格式错误。示例:Dev1/ai0、Dev1/ai0:7、Dev1/ao1、Dev1/port0/line0' };
}
三种通道类型:ai(模拟输入)、ao(模拟输出)、dio(数字 IO)。通道范围描述(如 Dev1/ai0:7)由 parseChannelRange 解析出 start/end/count,count 超过 16 路时发出警告。
5.3 DAQmx 配置综合校验
validateDaqConfig 对完整采集配置做五维校验:
// pyvisadaq-engine.js —— DAQmx 配置综合校验
function validateDaqConfig(cfg) {
var errors = [];
var warnings = [];
var ch = validateDaqChannel(cfg.channel || '');
if (!ch.valid) {
errors.push(ch.error);
} else if (ch.kind !== 'ai') {
errors.push('当前 AI 采集脚本要求模拟输入通道(ai),收到 ' + ch.kind + ' 类型通道');
} else {
var range = parseChannelRange(cfg.channel);
if (range && range.count > 16) {
warnings.push('通道范围覆盖 ' + range.count + ' 路,请确认硬件规模');
}
}
var rate = parseFloat(cfg.sampleRate);
if (!(rate > 0) || rate > 2000000) {
errors.push('采样率必须为 0 ~ 2 MS/s 之间的正数');
}
var samples = parseInt(cfg.samplesPerChan, 10);
if (!(samples > 0) || samples > 10000000) {
errors.push('每通道采样数必须为 0 ~ 10000000 之间的正整数');
}
var minV = parseFloat(cfg.minVal);
var maxV = parseFloat(cfg.maxVal);
if (isNaN(minV) || isNaN(maxV) || minV >= maxV) {
errors.push('量程设置无效:最小值必须小于最大值');
}
if (minV < -10 || maxV > 10) {
errors.push('量程超出范围:电压限制为 ±10 V');
}
return { valid: errors.length === 0, errors: errors, warnings: warnings, channelKind: ch.kind };
}
校验维度:通道类型(必须为 ai)、通道规模(>16 路警告)、采样率(0~2 MS/s)、采样数(0~10M 正整数)、量程(min<max 且不超 ±10V)。errors 数组非空则配置无效,warnings 数组仅做提示不阻断。
六、Python 脚本生成器
6.1 PyVISA 会话回放脚本
generateVisaScript 函数将当前会话的命令历史回放为可运行的 Python 脚本:
// pyvisadaq-engine.js —— PyVISA 会话回放脚本生成
function generateVisaScript(session) {
var res = session.resource || 'TCPIP0::192.168.1.100::INSTR';
var noResource = !session.resource;
var timeout = parseInt(session.timeout, 10) || 5000;
var term = session.termination || '\\n';
var cmds = session.commands || [];
var lines = [];
lines.push('#!/usr/bin/env python3');
lines.push('# -*- coding: utf-8 -*-');
lines = lines.concat(visaHeaderLines());
lines.push('# 读语义遵循 VPP-4.3 RULE 6.1:END 指示优先于终止符与字节计数');
lines.push('# (pyvisa-py 的 TCPIPInstrSession 会按 lan_device_name 自动分发 VXI-11 / HiSLIP)');
lines.push('');
lines.push('import pyvisa');
lines.push('');
lines.push('');
lines.push('RESOURCE = ' + pythonStr(res) + (noResource ? ' # 未配置资源,使用示例地址' : ''));
lines.push('');
lines.push('');
lines.push('def main():');
lines.push(' rm = pyvisa.ResourceManager()');
lines.push(' instrument = rm.open_resource(RESOURCE)');
lines.push(' instrument.timeout = ' + timeout);
lines.push(' instrument.write_termination = ' + pythonStr(term));
lines.push(' instrument.read_termination = ' + pythonStr(term));
lines.push('');
var played = 0, skipped = 0;
if (cmds.length === 0) {
lines.push(' # 会话中无命令记录,默认执行标识查询');
lines.push(' response = instrument.query("*IDN?")');
lines.push(' print(response.strip())');
} else {
lines.push(' # ===== 会话命令回放 =====');
cmds.forEach(function(c) {
var v = validateScpi(c.command);
if (!v.valid) {
lines.push(' # [已跳过无效命令] ' + c.command);
skipped++;
return;
}
played++;
if (v.isQuery) {
lines.push(' print(instrument.query(' + pythonStr(c.command) + ').strip()) # ' + v.header);
} else {
lines.push(' instrument.write(' + pythonStr(c.command) + ')');
}
});
lines.push(' # 回放统计:有效 ' + played + ' 条,跳过 ' + skipped + ' 条');
}
lines.push('');
lines.push(' instrument.close()');
lines.push(' # 也可使用上下文管理:with rm.open_resource(RESOURCE) as instrument:');
lines.push('');
lines.push('');
lines.push('if __name__ == "__main__":');
lines.push(' main()');
lines.push('');
return lines.join('\n');
}
生成逻辑要点:
- 资源未配置时使用示例地址 TCPIP0::192.168.1.100::INSTR 并加注释标记
- 命令回放前逐条调 validateScpi 校验,无效命令以注释形式跳过并统计
- 查询命令(isQuery=true)生成 instrument.query() 调用,写入命令生成 instrument.write() 调用
- pythonStr 辅助函数对字符串做转义处理(反斜杠和单引号)
6.2 PyDAQmx 采集脚本
generateDaqScript 函数基于 PyDAQmx 源码 API 生成采集脚本:
// pyvisadaq-engine.js —— PyDAQmx 采集脚本生成
function generateDaqScript(cfg) {
var channel = cfg.channel || 'Dev1/ai0';\n var rate = parseFloat(cfg.sampleSize) || 1000;
var samples = parseInt(cfg.samplesPerChan, 10) || 1000;
var minV = parseFloat(cfg.minVal);
var maxV = parseFloat(cfg.maxVal);
if (isNaN(minV)) { minV = -10; }
if (isNaN(maxV)) { maxV = 10; }
var termMap = {
'RSE': 'DAQmx_Val_RSE',
'NRSE': 'DAQmx_Val_NRSE',
'Diff': 'DAQmx_Val_Diff',
'PseudoDiff': 'DAQmx_Val_PseudoDiff'
};
var termConst = termMap[cfg.terminal] || 'DAQmx_Val_RSE';
var lines = [];
lines.push('#!/usr/bin/env python3');
lines.push('# -*- coding: utf-8 -*-');
lines.push('# ============================================================');
lines.push('# 由数采工作台生成的 DAQmx 采集脚本(基于 PyDAQmx 源码 API)');
lines.push('# ============================================================');
lines.push('# 注意:PyDAQmx 通过 ctypes 调用 NI-DAQmx 驱动库(libnicaiu),');
lines.push('# 运行主机需已安装 NI-DAQmx 驱动。鸿蒙平台暂无该驱动,');
lines.push('# 请在装有 NI-DAQmx 的 Windows/Linux 主机的 Python 上运行本脚本。');
lines.push('# 依赖:pip3 install PyDAQmx numpy');
lines.push('');
lines.push('from PyDAQmx import Task, int32, byref');
lines.push('from PyDAQmx.DAQmxConstants import (');
lines.push(' DAQmx_Val_RSE, DAQmx_Val_NRSE, DAQmx_Val_Diff, DAQmx_Val_PseudoDiff,');
lines.push(' DAQmx_Val_Volts, DAQmx_Val_Rising, DAQmx_Val_FiniteSamps,');
lines.push(' DAQmx_Val_GroupByChannel,');
lines.push(')');
lines.push('import numpy as np');
lines.push('');
// ... 后续生成 main() 函数体(Task 创建/通道配置/采样时钟/数据采集/清理)
return lines.join('\n');
}
脚本结构对齐 PyDAQmx 源码语义:Task() 构造函数内部执行 DAQmxCreateTask,方法名即 DAQmx 函数去前缀名,显式 ClearTask 与源码 del 的自动清理幂等(handle 置 0 后重复清理为 no-op)。4 种接线端配置(RSE / NRSE / Diff / PseudoDiff)通过 termMap 映射到 DAQmx 常量名。
6.3 VISA 资源扫描脚本
generateScanScript 生成 rm.list_resources + 逐个标识查询的扫描脚本:
// pyvisadaq-engine.js —— VISA 资源扫描脚本生成
function generateScanScript() {
var lines = [];
lines.push('#!/usr/bin/env python3');
lines.push('# -*- coding: utf-8 -*-');
lines = lines.concat(visaHeaderLines());
lines.push('');
lines.push('import pyvisa');
lines.push('');
lines.push('');
lines.push('def main():');
lines.push(' rm = pyvisa.ResourceManager()');
lines.push(' resources = rm.list_resources()');
lines.push(' print("发现", len(resources), "个 VISA 资源:")');
lines.push(' for r in resources:');
lines.push(' print(" ", r)');
lines.push(' try:');
lines.push(' with rm.open_resource(r) as inst:');
lines.push(' inst.timeout = 2000');
lines.push(' print(" *IDN? ->", inst.query("*IDN?").strip())');
lines.push(' except Exception as e:');
lines.push(' print(" 无法识别:", e)');
lines.push('');
lines.push('');
lines.push('if __name__ == "__main__":');
lines.push(' main()');
lines.push('');
return lines.join('\n');
}
扫描脚本使用 list_resources() 发现所有可用 VISA 资源,逐个打开并发送 *IDN? 查询标识,异常时打印错误信息。超时设为 2000ms 避免无响应设备阻塞。
七、会话管理与示例场景
7.1 全局状态管理
渲染进程通过 state 对象管理会话状态:
// renderer.js —— 全局状态
var state = {
resource: '',
termination: '\\n',
timeout: 5000,
commands: [],
currentScript: '',
currentScriptType: '',
sessionFile: null
};
var SCRIPT_NAMES = {
pyvisa: 'instrument_session.py',
daqmx: 'daqmx_acquire.py',
scan: 'scan_resources.py'
};
state 对象包含 7 个字段:资源地址、终止符、超时、命令历史、当前脚本内容、脚本类型、会话文件路径。SCRIPT_NAMES 映射表为三种脚本类型提供默认保存文件名。
7.2 会话文件存取
会话保存时通过 buildSessionData 构建 JSON 数据,经 file:saveSessionDialog + file:write 两个 IPC 通道完成:
// renderer.js —— 会话保存
function buildSessionData() {
return {
app: 'shucai',
resource: state.resource,
timeout: state.timeout,
termination: state.termination,
commands: state.commands
};
}
function saveSession() {
ipcRenderer.invoke('file:saveSessionDialog').then(function(result) {
if (result && !result.canceled && result.filePath) {
state.sessionFile = result.filePath;
var data = JSON.stringify(buildSessionData(), null, 2);
ipcRenderer.invoke('file:write', result.filePath, data).then(function(wr) {
if (wr.success) {
statusFile.textContent = result.filePath.split(/[\\/]/).pop();
statusInfo.textContent = '会话已保存';
} else {
statusInfo.textContent = '会话保存失败(详情见控制台)';
appendLine('[错误] 会话写入失败: ' + wr.error, 'console-error');
}
});
}
});
}
会话读取时经 file:openSession + file:read 两个 IPC 通道,解析 JSON 后恢复到 state 并同步 UI:
// renderer.js —— 会话读取
function openSession() {
ipcRenderer.invoke('file:openSession').then(function(result) {
if (result && !result.canceled && result.filePaths.length > 0) {
var filePath = result.filePaths[0];
ipcRenderer.invoke('file:read', filePath).then(function(rr) {
if (!rr.success) {
statusInfo.textContent = '会话读取失败(详情见控制台)';
appendLine('[错误] 读取失败: ' + rr.error, 'console-error');
return;
}
try {
var session = JSON.parse(rr.content);
state.resource = session.resource || '';
state.timeout = session.timeout || 5000;
state.termination = session.termination || '\\n';
state.commands = Array.isArray(session.commands) ? session.commands : [];
state.sessionFile = filePath;
cmdCount.textContent = '命令: ' + state.commands.length;
statusFile.textContent = filePath.split(/[\\/]/).pop();
statusInfo.textContent = '会话已载入';
applySessionToUi();
} catch (e) {
statusInfo.textContent = '会话文件格式无效';
appendLine('[错误] 会话解析失败: ' + e.message, 'console-error');
}
});
}
});
}
JSON 解析用 try-catch 包裹,格式错误时不会崩溃,而是在控制台输出错误信息。applySessionToUi 将 state 中的资源/超时/终止符同步回表单控件。
7.3 示例场景系统
引擎内置 5 个典型仪器会话示例,覆盖全部 VISA 资源类型:
// pyvisadaq-engine.js —— 示例场景定义
var EXAMPLES = [
{
id: 'dmm',
name: '数字万用表(DMM)',
tag: 'TCPIP · VXI-11',
desc: 'LAN 万用表典型流程:复位 → 标识查询 → 直流电压配置 → 触发读取。',
resource: 'TCPIP0::192.168.1.100::INSTR',
termination: '\\n',
timeout: 5000,
commands: ['*RST', '*IDN?', 'CONF:VOLT:DC 10, 0.001', 'READ?'],
daq: null
},
{
id: 'scope',
name: '示波器(USB)',
tag: 'USB · USBTMC',
desc: 'USB 示波器:自动设置后读取 CH1 峰峰值;含 SCPI 绝对路径(前导冒号)命令。',
resource: 'USB0::0x0699::0x0363::C000000::INSTR',
termination: '\\n',
timeout: 10000,
commands: ['*IDN?', '*RST', ':AUTOSET EXECUTE', ':MEAS:VPP? CHAN1'],
daq: null
},
{
id: 'funcgen',
name: '函数发生器(GPIB)',
tag: 'GPIB · PAD10',
desc: 'GPIB 函数发生器:配置正弦波、频率与幅度,打开输出。',
resource: 'GPIB0::10::INSTR',
termination: '\\n',
timeout: 5000,
commands: ['*RST', ':SOUR:FUNC SIN', ':SOUR:FREQ 1KHZ', ':SOUR:VOLT 2VPP', ':OUTP ON'],
daq: null
},
{
id: 'serial',
name: '串口仪器(ASRL)',
tag: '串口 · CRLF',
desc: '串口设备:CRLF 终止符下的标识查询与状态清除。',
resource: 'ASRL3::INSTR',
termination: '\\r\\n',
timeout: 3000,
commands: ['*IDN?', '*CLS'],
daq: null
},
{
id: 'daq',
name: 'DAQmx 电压采集',
tag: 'Dev1 · ai0:1',
desc: 'NI-DAQmx 双通道电压采集配置:1000 S/s、500 点、±10 V、RSE。生成脚本需在装有 NI 驱动的主机运行。',
resource: '',
termination: '\\n',
timeout: 5000,
commands: [],
daq: { channel: 'Dev1/ai0:1', rate: '1000', samples: '500', minVal: '-10', maxVal: '10', terminal: 'RSE' }
}
];
每个示例包含 id、name、tag(资源类型标签)、desc(描述)、resource(VISA 资源地址)、termination(终止符)、timeout(超时)、commands(SCPI 命令列表)、daq(DAQmx 配置,仅 daq 示例有值)。
渲染进程通过 renderExamples 动态创建 DOM 元素(避免 innerHTML 注入风险),点击示例后 applyExample 执行载入流程:重置会话 → 填入资源/DAQ 配置 → 逐条校验命令 → 切回控制台标签页。
八、SCPI 控制台与 UI 布局
8.1 SCPI 交互式控制台
控制台是用户与仪器交互的核心界面,支持手动输入命令和快捷按钮两种方式:
// renderer.js —— SCPI 控制台命令发送
function sendCommand(cmdText) {
var cmd = (cmdText || '').trim();
if (!cmd) return;
appendLine('> ' + cmd, 'console-cmd');
var v = validateScpi(cmd);
if (!v.valid) {
appendLine('[无效] ' + v.error, 'console-error');
return;
}
// 记录会话命令(用于生成 Python 回放脚本)
state.commands.push({ command: cmd, ts: new Date().toISOString() });
cmdCount.textContent = '命令: ' + state.commands.length;
// 真实解析结果反馈(不伪造仪器返回数据)
var kind = v.isCommon ? '通用命令' : '标准命令';
var detail = kind + (v.isQuery ? ' [查询]' : ' [写入]');
if (v.commonDesc) { detail += ' · ' + v.commonDesc; }
if (v.params.length > 0) {
var pstr = v.params.map(function(p, i) {
var t = v.paramTypes[i] || '';
return t ? p + ' <' + t + '>' : p;
}).join(', ');
detail += ' 参数(' + v.params.length + '): ' + pstr;
}
appendLine('[有效] ' + detail, 'console-ok');
statusInfo.textContent = '已记录: ' + v.header;
}
控制台设计原则:只校验不伪造——validateScpi 返回语法分析结果(有效/无效、通用/标准、查询/写入、参数类型),但不模拟仪器返回数据。命令记录到 state.commands 数组(带时间戳),供后续生成 Python 回放脚本使用。
HTML 中预设 7 个快捷命令按钮(*IDN? / *RST / *CLS / *OPC? / MEAS:VOLT? / READ? / CONF:VOLT),点击即发送。
8.2 标签页工作区
页面采用双标签页设计:SCPI 控制台和脚本预览。标签页切换通过 switchTab 函数实现:
// renderer.js —— 标签页切换
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(function(b) {
b.classList.toggle('active', b.getAttribute('data-tab') === name);
});
document.querySelectorAll('.tab-pane').forEach(function(p) {
p.classList.toggle('active', p.id === 'pane-' + name);
});
}
通过 data-tab 属性与 pane-id 的映射关系(pane-console / pane-script)控制显隐。CSS 中 .tab-pane 默认 display:none,.tab-pane.active 设为 display:flex。
8.3 分段按钮组
终止符选择和 DAQmx 接线端配置使用分段按钮组(seg-group),通过 setSegActive 函数同步视觉状态与数据状态:
// renderer.js —— 分段按钮组状态同步
function setSegActive(groupId, attr, value) {
var grp = byId(groupId);
var btns = grp.querySelectorAll('.seg-btn');
btns.forEach(function(b) {
if (b.getAttribute(attr) === value) {
b.classList.add('active');
} else {
b.classList.remove('active');
}
});
}
该函数在示例载入、会话读取、新建会话等场景下被调用,确保按钮视觉状态与 state 中的数据一致。终止符按钮组使用 data-term 属性(\n / \r\n / \r),接线端按钮组使用 data-daqterm 属性(RSE / NRSE / Diff / PseudoDiff)。
8.4 Catppuccin Mocha 深色主题
CSS 采用 Catppuccin Mocha 配色方案,通过 CSS 变量统一管理:
/* pyvisadaq.css —— CSS 变量定义 */
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--surface0: #313244;
--surface1: #45475a;
--surface2: #585b70;
--text: #cdd6f4;
--text-muted: #a6adc8;
--overlay0: #6c7086;
--blue: #89b4fa;
--green: #a6e3a1;
--red: #f38ba8;
--yellow: #f9e2af;
--mauve: #cba6f7;
--teal: #94e2d5;
--sky: #89dceb;
--pink: #f5c2e7;
--border: #45475a;
--border-light: #585b70;
--toolbar-height: 44px;
--statusbar-height: 26px;
--panel-width: 240px;
--font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
配色层次:base(主背景)→ mantle(面板背景)→ crust(工作区背景)→ surface0/1/2(按钮/输入框层级)→ text/text-muted(文字层级)→ mauve/green/teal(强调色)。表单提示使用三色语义:hint-ok(green)、hint-error(red)、hint-warn(yellow)。
8.5 grid 同格叠加弹层
关于弹层和示例弹层采用 grid 同格叠加方案,而非传统的 position:fixed 绝对定位:
/* pyvisadaq.css —— grid 同格叠加弹层 */
body {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
overflow: hidden;
}
.app-frame {
grid-area: 1 / 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.about-layer {
grid-area: 1 / 1;
display: none;
align-items: center;
justify-content: center;
background: rgba(17, 17, 27, 0.82);
}
.about-layer.open { display: flex; }
body 设为单格 grid(1 列 1 行),.app-frame 与 .about-layer 同处 grid-area:1/1 叠加。.about-layer 默认 display:none,添加 .open 类后变为 display:flex 居中显示。这种方案的优势是弹层显隐完全由 CSS 类控制,无需 JavaScript 操作 z-index 或定位属性,渲染进程崩溃恢复时也无需额外处理弹层状态。
九、常见问题与解决方案
Q1:SCPI 绝对路径命令(前导冒号)被误判为助记符为空
问题现象: 在控制台输入 :MEAS:VPP? CHAN1 时,校验结果显示"助记符为空(检查冒号位置)",但该命令在真实示波器上完全有效。
根因分析: SCPI-99 允许绝对路径命令以冒号开头表示从根节点开始的完整路径(如 :MEAS:VPP?),早期版本在 split(‘:’) 后第一个元素为空字符串,直接命中 m.length === 0 的校验失败分支。
解决: 在校验助记符之前,检测并剥离前导冒号:
// pyvisadaq-engine.js —— 前导冒号处理
// SCPI-99 绝对路径:允许一个前导冒号(如 :MEAS:VPP? 表示从根节点开始的完整路径)
if (headerPart.charAt(0) === ':') { headerPart = headerPart.slice(1); }
真实示波器/信号源大量使用绝对路径命令,不剥离会被误判为"助记符为空"并在回放脚本中被跳过。
Q2:TCPIP SOCKET 资源缺少端口号校验导致生成无效脚本
问题现象: 输入 TCPIP0::192.168.1.100::SOCKET 后校验通过,但生成的 PyVISA 脚本运行时抛出端口号无效异常。
根因分析: 早期 TCPIP 分支只检查末段是否为 SOCKET,未验证端口号是否存在且在有效范围内(1-65535)。
解决: 在 SOCKET 分支中增加端口号校验:
// pyvisadaq-engine.js —— SOCKET 端口号校验
if (tlast === 'SOCKET') {
if (tparts.length < 3) {
out.error = 'SOCKET 资源缺少端口号(格式:TCPIP[board]::host::port::SOCKET)';
return out;
}
var port = tparts[tparts.length - 2];
if (!/^\d+$/.test(port) || parseInt(port, 10) < 1 || parseInt(port, 10) > 65535) {
out.error = 'SOCKET 端口号无效:' + port + '(应为 1-65535)';
return out;
}
out.valid = true;
out.resourceClass = 'SOCKET';
out.protocol = '原始 TCP Socket(对应 pyvisa-py TCPIPSocketSession)';
out.target = tparts[0] + ':' + port;
return out;
}
SOCKET 格式为 TCPIP[board]::host::port::SOCKET,tparts 分割后倒数第二段为端口号,必须为 1-65535 的正整数。
Q3:DAQmx 配置校验未检查通道类型导致生成错误脚本
问题现象: 用户输入 Dev1/ao0(模拟输出通道)后点击"校验配置",显示配置有效,但生成的 DAQmx 采集脚本运行时报"通道类型不匹配"错误。
根因分析: 早期 validateDaqConfig 只检查通道名格式是否合法,未验证通道类型是否为 AI(模拟输入),而采集脚本只能使用 AI 通道。
解决: 在通道校验通过后增加类型检查:
// pyvisadaq-engine.js —— DAQmx 通道类型检查
var ch = validateDaqChannel(cfg.channel || '');
if (!ch.valid) {
errors.push(ch.error);
} else if (ch.kind !== 'ai') {
errors.push('当前 AI 采集脚本要求模拟输入通道(ai),收到 ' + ch.kind + ' 类型通道');
} else {
var range = parseChannelRange(cfg.channel);
if (range && range.count > 16) {
warnings.push('通道范围覆盖 ' + range.count + ' 路,请确认硬件规模');
}
}
AI 采集脚本要求通道类型为 ai,若用户输入 ao(模拟输出)或 dio(数字 IO)通道则报错,通道范围超过 16 路时发出警告但不阻断。
Q4:示例场景载入后终止符状态不同步导致脚本生成错误
问题现象: 载入"串口仪器(ASRL)"示例后,界面显示终止符为 \r\n,但生成的 PyVISA 脚本中终止符仍为 \n。
根因分析: applyExample 中调用 setSegActive 只切换了按钮视觉状态,未同步更新 state.termination,导致 generateVisaScript 读取到旧的终止符。
解决: 在 setSegActive 之前显式赋值 state.termination:
// renderer.js —— 示例载入时终止符同步
if (ex.resource) {
resourceInput.value = ex.resource;
timeoutInput.value = String(ex.timeout || 5000);
// 同步状态:setSegActive 只切按钮视觉,state.termination 需显式赋值
state.termination = ex.termination || '\\n';
setSegActive('termGroup', 'data-term', ex.termination || '\\n');
applyResource();
}
setSegActive 只负责按钮视觉切换,state 数据需显式赋值,否则生成脚本仍用旧终止符。
Q5:会话读取后控制台未清空导致历史命令混杂
问题现象: 打开一个会话文件后,控制台显示的是新会话的命令记录与旧会话的控制台输出混杂在一起。
根因分析: openSession 成功后只调用了 applySessionToUi 同步表单控件,未清空 consoleOutput 中的旧内容。
解决: 在 newSession 中增加控制台清空逻辑,openSession 不主动清空(保留用户当前上下文),但新会话时完全重置:
// renderer.js —— 新建会话时清空控制台
function newSession() {
state.commands = [];
state.resource = '';
state.timeout = 5000;
state.termination = '\\n';
state.sessionFile = null;
state.currentScript = '';
state.currentScriptType = '';
cmdCount.textContent = '命令: 0';
statusFile.textContent = '未保存';
statusInfo.textContent = '新建会话';
applySessionToUi();
codePreview.textContent = '# 点击工具栏「PyVISA 脚本 / DAQmx 脚本 / 扫描脚本」生成对应脚本\n# 生成的脚本可在装有 Python 3 与 pyvisa / pyvisa-py 的机器上运行';
scriptHint.textContent = '';
scriptHint.className = 'form-hint';
// 清空控制台与左侧表单残留,恢复到初始状态
consoleOutput.textContent = '';
appendLine('输入 SCPI 命令后回车发送。常用:*IDN? *RST MEAS:VOLT?DC', 'console-info');
consoleInput.value = '';
resourceHint.textContent = 'TCPIP / USB / GPIB / ASRL(COM3 为 ASRL 别名)';
resourceHint.className = 'form-hint';
byId('daqChannel').value = '';
byId('daqRate').value = '1000';
byId('daqSamples').value = '1000';
byId('daqMin').value = '-10';
byId('daqMax').value = '10';
setSegActive('daqTermGroup', 'data-daqterm', 'RSE');
var daqHint = byId('daqHint');
daqHint.textContent = '';
daqHint.className = 'form-hint';
switchTab('console');
}
newSession 完全重置所有状态:命令列表、资源、超时、终止符、脚本、控制台输出、左侧表单、DAQ 配置。openSession 则只同步表单不清控制台,保留用户当前上下文。
Q6:修改了代码,但 HAP 安装后没有生效
问题现象: 修改了 renderer.js、pyvisadaq-engine.js 等文件,重新构建 HAP 安装后,应用行为没有任何变化。
根因分析: 鸿蒙壳工程的实际运行文件从 web_engine/src/main/resources/resfile/resources/app/ 部署目录加载,而非开发目录。修改后必须手动同步。
解决: 每次修改后执行同步命令:
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron-apps/PyVISADAQ/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
验证方法: 同步后用文件哈希对比确认一致性。
Q7:AGC 审核驳回——requestPermissions 声明了受限 ACL 权限
问题现象: AGC 审核驳回,指出 web_engine/src/main/module.json5 中声明了受限 ACL 权限,但应用隐私政策中未声明对应用途。
根因分析: Electron 壳工程模板默认声明了大量系统权限,数采工作台作为仪器控制工具不需要位置、相机、麦克风等权限。
解决: 精简权限声明,只保留应用实际使用的 14 项系统权限,移除 FILE_ACCESS_PERSIST、READ_WRITE_DESKTOP_DIRECTORY、READ_PASTEBOARD、CAMERA、MICROPHONE 等不需要的权限。数采工作台无剪贴板功能,READ_PASTEBOARD 旁路不适用。
十、总结
本文完整记录了在鸿蒙 PC 平台上适配数采工作台的全过程。通过 Electron 壳方案,我们实现了一个具备仪器通信协议校验、多类型脚本生成、会话持久化的数据采集工作台,核心特性包括:
| 特性 | 实现方案 |
|---|---|
| SCPI 校验 | IEEE 488.2 通用命令表(16 条)+ SCPI-99 助记符语法分析 + 5 种参数类型识别 |
| VISA 解析 | TCPIP(VXI-11 / HiSLIP / SOCKET)+ USB(USBTMC)+ GPIB + ASRL 全协议族正则匹配 |
| DAQmx 校验 | 通道命名正则(ai / ao / dio)+ 采样率/采样数/量程/接线端五维校验 |
| 脚本生成 | PyVISA 会话回放 + PyDAQmx 采集 + VISA 资源扫描(3 类脚本,基于源码 API) |
| 会话管理 | JSON 文件存取 + 5 个 IPC 通道 + 状态栏文件路径显示 |
| 示例场景 | 5 个预设(DMM/示波器/函数发生器/串口仪器/DAQmx)+ DOM 动态渲染 |
| SCPI 控制台 | 命令输入/回车发送 + 7 个快捷按钮 + 语法校验反馈 + 命令计数 |
| 弹层系统 | grid 同格叠加 + .open 类显隐切换(关于 / 示例两个弹层) |
| 鸿蒙安全 | 菜单双保险 + 导航拦截 + 新窗口拦截 + 禁用硬件加速 |
| 深色主题 | Catppuccin Mocha 配色 + CSS 变量体系 + 三色语义提示 |
适配过程中最关键的三个点:
- SCPI 绝对路径命令处理——SCPI-99 允许前导冒号表示从根节点开始的完整路径(如 :MEAS:VPP?),真实示波器/信号源大量使用,校验时必须先剥离前导冒号再 split(‘:’) 分割助记符,否则第一个元素为空字符串会被误判为格式错误,导致回放脚本中该命令被跳过
- VISA 资源协议细分——TCPIP INSTR 资源需按 lan_device_name 是否以 hislip 开头区分 VXI-11 与 HiSLIP 协议,SOCKET 资源需校验端口号 1-65535,USB 资源必须以 ::INSTR 结尾且厂商 ID 通常为 0x 前缀十六进制,GPIB 主/次地址范围 0-30,ASRL 支持板载串口和 Linux 设备路径两种形式,COM3 等 Windows 风格名为 ASRL 别名
- grid 同格叠加弹层方案——body 设为单格 grid,.app-frame 与 .about-layer 同处 grid-area:1/1 叠加,弹层显隐完全由 .open 类控制,无需 JavaScript 操作 z-index 或定位属性,在鸿蒙 ArkWeb 引擎上兼容性良好,渲染进程崩溃恢复时也无需额外处理弹层状态
这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版仪器控制与数据采集工作台。
更多推荐


所有评论(0)