从零搭建 VisionMaster 自动上传系统
🧭 从零搭建 VisionMaster 自动上传系统
这篇文章完整记录了我在 Ubuntu 服务器上部署 FTP 服务(vsftpd)、通过 WinSCP 客户端验证连接、并在 海康 VisionMaster 中用 C# 脚本实现自动创建 DFS 文件与图像存储目录 的全过程。
🧩 一、项目背景与需求说明
在实际的生产检测场景中(例如 面板检测 / AOI / 外观检测 系统),检测设备往往需要将检测结果上传到服务器进行集中存储与 MES 对接。
而在海康的 VisionMaster 平台中,流程节点可以运行 C# 脚本,因此可以实现自定义的上传逻辑。
🔧 需求目标
实现检测系统自动在 FTP 服务器上生成对应目录与文件结构。
具体需求如下:
| 模块 | 功能描述 |
|---|---|
| 1. 文件创建 | 检测完成后自动创建 3 个 DFS 空文件(.pnl.mes.dmy, .pnl.dmy, .pnl) |
| 2. 图像目录创建 | 根据 PanelID 规则(4/3/3/2/2/2)生成层级目录,用于保存检测图片 |
| 3. FTP 上传 | 文件和目录均在远程 FTP 服务器上创建 |
| 4. 可配置参数 | FTP 地址、用户名、密码、设备编号、路径根目录等均可在全局变量中设定 |
| 5. 日志输出 | 执行过程实时记录,便于调试与追踪 |
| 6. 文件覆盖控制 | 可选择是否允许覆盖同名文件(Overwrite=1 覆盖,0 跳过) |
系统整体结构如下:
VisionMaster (C# 脚本)│├─ 自动创建文件 → /data/{设备号}/...├─ 自动创建目录 → /image/{设备号}/Z667/55G/P22/08/B1/08│▼
FTP 服务器(Ubuntu + vsftpd)└─ 统一存储检测数据
📦 二、在服务器上部署 FTP 服务(vsftpd)
1️⃣ 安装 vsftpd
在 Ubuntu 上执行:
sudo apt update
sudo apt install vsftpd -y
2️⃣ 创建独立的 FTP 用户
出于安全考虑,我们不使用 root,而是创建一个独立账号:
sudo adduser vmftp
设置密码后,系统会自动创建主目录 /home/vmftp。
3️⃣ 修改配置文件
编辑 /etc/vsftpd.conf:
sudo nano /etc/vsftpd.conf
确认或添加以下内容(建议直接替换原文件):
# 基础设置
listen=YES
listen_ipv6=NO
anonymous_enable=NO
local_enable=YES
write_enable=YES# 允许本地用户上传
chroot_local_user=YES
allow_writeable_chroot=YES# 支持中文文件名
utf8_filesystem=YES# 开启被动模式端口范围(根据需要调整)
pasv_enable=YES
pasv_min_port=40000
pasv_max_port=40100
# 替换为你的公网 IP
pasv_address=公网 IP# 安全选项
user_sub_token=$USER
local_root=/home/$USER
seccomp_sandbox=NO
保存后重启:
sudo systemctl restart vsftpd
sudo systemctl enable vsftpd
sudo systemctl status vsftpd
如果显示:
Active: active (running)
说明配置成功。
4️⃣ 设置目录权限
创建两个存储目录:
sudo mkdir -p /home/vmftp/data/800MC0
sudo mkdir -p /home/vmftp/image/800MC0
sudo chown -R vmftp:vmftp /home/vmftp
5️⃣ 防火墙放行
如果启用了 UFW:
sudo ufw allow 21/tcp
sudo ufw allow 40000:40100/tcp
sudo ufw reload
🖥️ 三、FTP 客户端工具对比
| 客户端名称 | 主要特点 | 是否中文 | 推荐度 |
|---|---|---|---|
| WinSCP | 免费、稳定、界面直观,可保存站点配置 | ✅ 支持中文 | ⭐⭐⭐⭐⭐ |
| FileZilla | 开源跨平台,界面较复杂,适合开发者 | ✅ 支持中文 | ⭐⭐⭐⭐ |
| FlashFXP | 支持多站点并发、同步上传,但为付费软件 | ✅ 支持中文 | ⭐⭐⭐ |
| Cyberduck | 界面简洁,支持 WebDAV、S3、FTP 等协议 | ❌ 英文界面 | ⭐⭐ |
| Windows 自带 FTP | 命令行简洁但功能有限,适合基础调试 | ❌ 英文命令行 | ⭐ |
👉 推荐使用 WinSCP:简单好用,能快速验证你的 FTP 是否配置正确。
WinSCP 连接配置
| 参数 | 示例值 |
|---|---|
| 协议 | FTP |
| 加密 | 不加密 |
| 主机名 | IP 地址 |
| 端口号 | 21 |
| 用户名 | vmftp |
| 密码 | (刚设置的) |
| 传输模式 | 被动 (Passive) |
登录后右侧显示 /(实际对应 /home/vmftp)。
如果能上传文件 → 表示 FTP 服务工作正常。
⚙️ 四、VisionMaster 实现自动创建文件与目录
1️⃣ 创建全局变量
在 VisionMaster → 全局变量 → 添加以下变量组(建议命名为 “FTP配置”):
| 序号 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 1 | Host | string | FTP 服务器地址,例如 1.1.1.1 |
| 2 | Username | string | FTP 用户名,例如 vmftp |
| 3 | Password | string | FTP 密码 |
| 4 | PanelID | string | 面板编号(例如 Z66755GP2208B108) |
| 5 | StepID | string | 工站编号 |
| 6 | EQID | string | 设备编号 |
| 7 | DeviceID | string | 存图设备号 |
| 8 | DataRoot | string | 数据根目录(示例:/data/800MC0) |
| 9 | ImageRoot | string | 图像根目录(示例:/image/800MC0) |
| 10 | Overwrite | int | 是否覆盖(1=覆盖,0=跳过) |

2️⃣ 创建 C# 脚本节点
在流程中添加 C# Script Node,粘贴以下代码:
✅ 已在 .NET Framework 4.6.1 下测试通过
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Windows.Forms;
using Script.Methods;
/************************************
Shell Module default code: using .NET Framwwork 4.6.1
*************************************/
public partial class UserScript:ScriptMethods,IProcessMethods
{ //the count of process//执行次数计数int processCount ;// 私有日志缓冲:用于拼接日志,再一次性写到 Log(避免读取 Log)private StringBuilder logBuf = new StringBuilder();/// <summary>/// Initialize the field's value when compiling/// 预编译时变量初始化/// </summary>public void Init(){//You can add other global fields here//变量初始化,其余变量可在该函数中添加AppendLog("Init 开始");processCount = 0;logBuf.Clear();}/// <summary>/// Enter the process function when running code once/// 流程执行一次进入Process函数/// </summary>/// <returns></returns>public bool Process(){//You can add your codes here, for realizing your desired function//每次执行将进入该函数,此处添加所需的逻辑流程处理 logBuf.Clear();AppendLog("Process 开始");try {AppendLog("Host=" + Host + ", User=" + Username);AppendLog("PanelID=" + PanelID + ", StepID=" + StepID + ", EQID=" + EQID + ", DeviceID=" + DeviceID);AppendLog("DataRoot=" + DataRoot + ", ImageRoot=" + ImageRoot + ", Overwrite=" + Overwrite);DateTime now = DateTime.Now;// 创建DFS文件string baseNameLocal = CreateDfsFiles(Host, Username, Password, PanelID, StepID, EQID, now, Overwrite == 1);AppendLog("DFS文件创建完成: base=" + baseNameLocal);FileBaseName = baseNameLocal;// 创建图片路径string imageDirLocal = EnsureImageFolders(Host, Username, Password, DeviceID, PanelID);AppendLog("图片目录创建完成: " + imageDirLocal);ImageDir = imageDirLocal;processCount++;AppendLog("Process 结束(第 " + processCount + " 次)");// 输出日志Log = logBuf.ToString();return true;}catch (WebException wex) {string msg = "WebException: " + wex.Message;FtpWebResponse r = wex.Response as FtpWebResponse;if (r != null) {msg += " | Status=" + r.StatusCode + " (" + r.StatusDescription + ")";}AppendLog(msg);Log = logBuf.ToString();return false;}catch (Exception ex) {AppendLog("Exception: " + ex.Message);Log = logBuf.ToString();return false;}}// ========== 创建三个 0KB 文件 ==========private string CreateDfsFiles(string host, string user, string pass, string panelId, string stepId, string eqId, DateTime ts, bool overwrite){string date = ts.ToString("yyyyMMdd");string time = ts.ToString("HHmmss");string baseName = panelId + "_" + stepId + "_" + eqId + "_" + date + "_" + time;string[] files = new string[] {baseName + ".pnl.mes.dmy",baseName + ".pnl.dmy",baseName + ".pnl"};AppendLog("确保目录存在: " + DataRoot);EnsureFtpDirectory(host, user, pass, DataRoot);for (int i = 0; i < files.Length; i++) {string remotePath = DataRoot + "/" + files[i];AppendLog("上传(0KB): " + remotePath);UploadBytes(host, user, pass, remotePath, new byte[0], overwrite);}return baseName;}// ========== 创建存图目录(4/3/3/2/2/2 结构) ==========private string EnsureImageFolders(string host, string user, string pass, string deviceId, string panelId){string[] seg = SplitPanelId(panelId);// /image/800MC0/{DeviceID}/{4}/{3}/{3}/{2}/{2}/{2}string fullPath = ImageRoot + "/" + deviceId + "/" + string.Join("/", seg);AppendLog("确保图片目录存在: " + fullPath);EnsureFtpDirectory(host, user, pass, fullPath);return fullPath;}// ========== PanelID 分段工具:4/3/3/2/2/2 ==========private string[] SplitPanelId(string panelId){int[] spec = new int[] { 4, 3, 3, 2, 2, 2 };int needLen = 0;for (int i = 0; i < spec.Length; i++) {needLen += spec[i];}if (string.IsNullOrEmpty(panelId) || panelId.Length < needLen) {throw new Exception("PanelID长度不足(需≥" + needLen.ToString() + "):当前为 " + (panelId == null ? "null" : panelId));}string[] segs = new string[spec.Length];int pos = 0;for (int i = 0; i < spec.Length; i++) {segs[i] = panelId.Substring(pos, spec[i]);pos += spec[i];}return segs;}// ========== 逐级确保 FTP 目录存在 ==========private void EnsureFtpDirectory(string host, string user, string pass, string fullPath){string trimmed = fullPath.Trim('/');if (trimmed.Length == 0) {return;}string[] parts = trimmed.Split('/');string cur = "";for (int i = 0; i < parts.Length; i++) {cur += "/" + parts[i];TryMakeDirectory(host, user, pass, cur);}}private void TryMakeDirectory(string host, string user, string pass, string path){// ftp://{host}{path}string uri = "ftp://" + host + Uri.EscapeUriString(path);FtpWebRequest req = (FtpWebRequest)WebRequest.Create(uri);req.Method = WebRequestMethods.Ftp.MakeDirectory;req.Credentials = new NetworkCredential(user, pass);req.UsePassive = true;req.UseBinary = true;req.KeepAlive = false;try {using (FtpWebResponse resp = (FtpWebResponse)req.GetResponse()) { }}catch (WebException ex) {FtpWebResponse resp = ex.Response as FtpWebResponse;if (resp != null && resp.StatusCode == FtpStatusCode.ActionNotTakenFileUnavailable) {AppendLog("mkdir Exists: " + path);}else {AppendLog("mkdir FAIL: " + path + " | " + ex.Message);throw;}}}// ========== 上传字节(用于 0KB 文件) ==========private void UploadBytes(string host, string user, string pass, string remotePath, byte[] data, bool overwrite){if (!overwrite && FtpFileExists(host, user, pass, remotePath)) {AppendLog("跳过上传(已存在): " + remotePath);return;}string uri = "ftp://" + host + Uri.EscapeUriString(remotePath);FtpWebRequest req = (FtpWebRequest)WebRequest.Create(uri);req.Method = WebRequestMethods.Ftp.UploadFile;req.Credentials = new NetworkCredential(user, pass);req.UsePassive = true;req.UseBinary = true;req.KeepAlive = false;using (Stream s = req.GetRequestStream()) {if (data != null && data.Length > 0) {s.Write(data, 0, data.Length);}// data.Length==0 时,直接关闭流即可实现 0KB 文件}using (FtpWebResponse resp = (FtpWebResponse)req.GetResponse()) { }AppendLog("上传成功: " + remotePath);}// ========== 探测文件是否存在 ==========private bool FtpFileExists(string host, string user, string pass, string remotePath){try {string uri = "ftp://" + host + Uri.EscapeUriString(remotePath);FtpWebRequest req = (FtpWebRequest)WebRequest.Create(uri);req.Method = WebRequestMethods.Ftp.GetFileSize;req.Credentials = new NetworkCredential(user, pass);req.UsePassive = true;req.UseBinary = true;req.KeepAlive = false;using (FtpWebResponse resp = (FtpWebResponse)req.GetResponse()) { }return true;}catch (WebException ex) {FtpWebResponse resp = ex.Response as FtpWebResponse;if (resp != null && resp.StatusCode == FtpStatusCode.ActionNotTakenFileUnavailable) {return false;}AppendLog("GetFileSize 异常: " + ex.Message);throw;}}// ========== 日志工具 ==========private void AppendLog(string line){string ts = DateTime.Now.ToString("HH:mm:ss.fff");logBuf.Append("[").Append(ts).Append("] ").Append(line).Append('\n');}
}


📜 五、调试输出日志示例
脚本执行后,Log 变量会打印详细信息:
[11:39:58.924] Process 开始
[11:39:59.460] mkdir Exists: /data
[11:40:00.245] 上传成功: /data/800MC0/Z66755GP2208B108_700400_M1OLB0350_20251031_113958.pnl.mes.dmy
[11:40:09.397] 图片目录创建完成: /image/800MC0/M1OLB0350/Z667/55G/P22/08/B1/08
[11:40:09.397] Process 结束 (第 1 次)
在 WinSCP 中可见:
/home/vmftp/data/800MC0/
├── Z66755GP2208B108_700400_M1OLB0350_20251031_113958.pnl
├── ...
/home/vmftp/image/800MC0/M1OLB0350/Z667/55G/P22/08/B1/08/

🎯 六、总结
| 模块 | 说明 |
|---|---|
| vsftpd | 轻量、稳定的 FTP 服务,支持被动模式与中文路径 |
| WinSCP | 简洁易用的 GUI 客户端,调试阶段查看非常方便 |
| VisionMaster 脚本 | 自动生成 3 个 DFS 空文件并创建图像目录结构(4/3/3/2/2/2) |
| 日志系统 | 脚本内部记录所有上传行为,方便排查 |
