一、项目背景:为什么要做这个上位机?

上个月接了个电子厂的小需求:车间里3台视觉检测机(检测PCB焊点)的数据全存在本地电脑里,MES系统(生产执行系统)要实时拿检测结果、NG原因和设备状态——之前靠质检员手抄数据,一天错好几次,生产线组长天天吐槽。

工厂的痛点很明确:

  1. 视觉系统和MES是“信息孤岛”,数据不通;
  2. 不想装笨重的中间件(比如工业网关),成本高还难维护;
  3. 要求断网时数据不丢,联网后自动补发。

最终决定用C#做个轻量级上位机:一边连视觉检测机的SDK拿数据,一边用OPC UA给MES传数据,中间加个SQLite缓存兜底。全程没用到复杂框架,开发周期一周,部署后稳定跑了快一个月——这篇就把实战细节拆透,中小工厂的同类需求直接抄作业就行。

二、需求拆解与技术选型(不做无用功)

先把需求掰碎,避免开发时跑偏:

1. 核心需求清单

模块 具体要求
视觉数据采集 实时获取检测结果(PASS/NG)、NG原因(比如“焊点偏移”)、设备状态(运行/待机)
MES数据上传 按MES的OPC UA协议推送数据,频率1秒/次
断网缓存 网络断开时数据存本地,联网后自动补发
状态监控 简单UI显示设备连接状态、数据上传日志,方便车间工人查看

2. 技术选型(轻量优先)

  • 开发框架:C# WinForms(比WPF快,车间电脑跑着不卡)+ .NET Framework 4.7.2(兼容性好,老电脑也能装);
  • 视觉对接:用视觉检测机厂家提供的SDK(C#版,封装了TCP通信接口);
  • MES通信:OPC UA客户端(工业标准协议,MES系统原生支持);
  • 本地缓存:SQLite(轻量,无需安装,单文件数据库);
  • 日志记录:log4net(简单配置就能用,排查问题方便)。

为什么不用数据库直连MES?MES是甲方的核心系统,不让直接连数据库,OPC UA是他们指定的接口;为什么不用Python?车间维护人员只会看C#的exe,Python脚本怕他们误删。

三、核心开发:从代码层面打通“任督二脉”

这部分贴的都是项目里跑通的核心代码,注释写得细,改改参数就能用。

1. 第一步:封装视觉系统SDK对接类

视觉厂家给的SDK其实是个DLL,里面有VisionClient类,封装了TCP连接和数据读取。我们再包一层,处理重连和异常:

using System;
using System.Threading;
using VisionSDK; // 厂家提供的SDK命名空间

namespace VisionMesBridge
{
    /// <summary>
    /// 视觉检测系统数据采集类
    /// </summary>
    public class VisionDataCollector
    {
        private VisionClient _visionClient;
        private Thread _collectThread;
        private bool _isRunning = false;
        private string _visionIp = "192.168.1.101"; // 视觉机IP
        private int _visionPort = 8080;

        /// <summary>
        /// 收到视觉数据的回调
        /// </summary>
        public Action<VisionResult> OnVisionDataReceived;

        /// <summary>
        /// 启动数据采集
        /// </summary>
        public bool StartCollect()
        {
            try
            {
                _visionClient = new VisionClient();
                // 连接视觉机,超时3秒
                bool connectSuccess = _visionClient.Connect(_visionIp, _visionPort, 3000);
                if (!connectSuccess)
                {
                    LogHelper.Error("视觉机连接失败");
                    return false;
                }

                _isRunning = true;
                _collectThread = new Thread(CollectLoop) { IsBackground = true };
                _collectThread.Start();
                LogHelper.Info("视觉数据采集启动成功");
                return true;
            }
            catch (Exception ex)
            {
                LogHelper.Error($"视觉采集启动异常:{ex.Message}");
                return false;
            }
        }

        /// <summary>
        /// 采集循环(异步避免阻塞UI)
        /// </summary>
        private void CollectLoop()
        {
            while (_isRunning)
            {
                try
                {
                    // 读取最新检测结果(SDK方法)
                    VisionResult result = _visionClient.GetLatestResult();
                    if (result != null && result.IsValid)
                    {
                        // 触发回调,传给上层处理
                        OnVisionDataReceived?.Invoke(result);
                    }
                    Thread.Sleep(1000); // 1秒采集一次,和视觉机频率匹配
                }
                catch (Exception ex)
                {
                    LogHelper.Error($"采集数据异常:{ex.Message}");
                    // 断连后自动重连
                    ReconnectVision();
                    Thread.Sleep(3000); // 重连间隔3秒,别太频繁
                }
            }
        }

        /// <summary>
        /// 视觉机重连逻辑
        /// </summary>
        private void ReconnectVision()
        {
            if (_visionClient != null)
            {
                _visionClient.Disconnect();
            }
            bool reconnectSuccess = _visionClient.Connect(_visionIp, _visionPort, 3000);
            if (reconnectSuccess)
            {
                LogHelper.Info("视觉机重连成功");
            }
            else
            {
                LogHelper.Error("视觉机重连失败,3秒后重试");
            }
        }

        /// <summary>
        /// 停止采集
        /// </summary>
        public void StopCollect()
        {
            _isRunning = false;
            if (_collectThread != null && _collectThread.IsAlive)
            {
                _collectThread.Join(2000);
            }
            if (_visionClient != null)
            {
                _visionClient.Disconnect();
            }
            LogHelper.Info("视觉数据采集已停止");
        }
    }

    /// <summary>
    /// 视觉检测结果实体(和SDK返回结构对应)
    /// </summary>
    public class VisionResult
    {
        public bool IsValid { get; set; } // 数据是否有效
        public string SN { get; set; } // 产品序列号
        public bool IsPass { get; set; } // PASS/NG
        public string NgReason { get; set; } // NG原因
        public string DeviceStatus { get; set; } // 设备状态:Running/Standby/Error
        public DateTime CollectTime { get; set; } // 采集时间
    }
}

2. 第二步:OPC UA客户端封装(对接MES)

MES系统用的是OPC UA服务器,我们需要用C#的OPC UA客户端库(选Opc.Ua.Client,NuGet直接装)来上传数据:

using Opc.Ua;
using Opc.Ua.Client;
using System;

namespace VisionMesBridge
{
    /// <summary>
    /// MES系统OPC UA客户端
    /// </summary>
    public class MesOpcUaClient
    {
        private Session _opcSession;
        private string _opcServerUrl = "opc.tcp://192.168.1.200:4840"; // MES的OPC UA地址
        private string _opcUsername = "mes_user";
        private string _opcPassword = "Mes@123456";

        /// <summary>
        /// 连接MES的OPC UA服务器
        /// </summary>
        public bool Connect()
        {
            try
            {
                var endpointUrl = _opcServerUrl;
                var endpoint = new ConfiguredEndpoint(null, new EndpointDescription(endpointUrl));
                // 设置登录凭据
                var userIdentity = new UserIdentity(_opcUsername, _opcPassword);
                // 创建会话
                _opcSession = Session.Create(
                    new ApplicationConfiguration(),
                    endpoint,
                    false,
                    "VisionMesBridge",
                    60000,
                    userIdentity,
                    null
                ).Result;
                LogHelper.Info("OPC UA连接MES成功");
                return true;
            }
            catch (Exception ex)
            {
                LogHelper.Error($"OPC UA连接失败:{ex.Message}");
                return false;
            }
        }

        /// <summary>
        /// 上传数据到MES的OPC UA节点
        /// </summary>
        /// <param name="nodeId">MES的节点ID(比如"ns=2;s=VisionData.SN")</param>
        /// <param name="value">要上传的值</param>
        public bool UploadData(string nodeId, object value)
        {
            if (_opcSession == null || !_opcSession.Connected)
            {
                LogHelper.Warn("OPC UA未连接,尝试重连");
                if (!Reconnect())
                {
                    return false;
                }
            }

            try
            {
                // 写入节点值
                var writeValue = new WriteValue
                {
                    NodeId = new NodeId(nodeId),
                    AttributeId = Attributes.Value,
                    Value = new DataValue(new Variant(value))
                };
                _opcSession.Write(null, new[] { writeValue }, out var results);
                if (results[0].StatusCode.IsGood())
                {
                    LogHelper.Info($"上传数据成功:{nodeId} = {value}");
                    return true;
                }
                else
                {
                    LogHelper.Error($"上传数据失败:{results[0].StatusCode}");
                    return false;
                }
            }
            catch (Exception ex)
            {
                LogHelper.Error($"上传数据异常:{ex.Message}");
                return false;
            }
        }

        /// <summary>
        /// OPC UA重连
        /// </summary>
        private bool Reconnect()
        {
            try
            {
                _opcSession?.Reconnect();
                return _opcSession != null && _opcSession.Connected;
            }
            catch
            {
                return Connect(); // 重连失败就重新创建会话
            }
        }

        /// <summary>
        /// 断开连接
        /// </summary>
        public void Disconnect()
        {
            _opcSession?.Close();
            _opcSession?.Dispose();
            LogHelper.Info("OPC UA已断开连接");
        }
    }
}

3. 第三步:本地缓存与数据补发(断网兜底)

用SQLite做本地缓存,先建个VisionData表,结构很简单:

CREATE TABLE IF NOT EXISTS VisionData (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    SN TEXT,
    IsPass BOOLEAN,
    NgReason TEXT,
    DeviceStatus TEXT,
    CollectTime DATETIME,
    IsUploaded BOOLEAN DEFAULT 0 -- 是否已上传MES
);

然后封装缓存操作类,处理数据存储和补发:

using System;
using System.Data.SQLite;
using System.Linq;

namespace VisionMesBridge
{
    /// <summary>
    /// SQLite本地缓存助手
    /// </summary>
    public class SqliteCacheHelper
    {
        private string _dbPath = "VisionDataCache.db"; // 数据库文件路径

        /// <summary>
        /// 初始化数据库(创建表)
        /// </summary>
        public void InitDb()
        {
            using (var conn = new SQLiteConnection($"Data Source={_dbPath};Version=3;"))
            {
                conn.Open();
                string createTableSql = @"
                    CREATE TABLE IF NOT EXISTS VisionData (
                        Id INTEGER PRIMARY KEY AUTOINCREMENT,
                        SN TEXT,
                        IsPass BOOLEAN,
                        NgReason TEXT,
                        DeviceStatus TEXT,
                        CollectTime DATETIME,
                        IsUploaded BOOLEAN DEFAULT 0
                    );";
                using (var cmd = new SQLiteCommand(createTableSql, conn))
                {
                    cmd.ExecuteNonQuery();
                }
            }
        }

        /// <summary>
        /// 保存视觉数据到本地缓存
        /// </summary>
        public void SaveToCache(VisionResult result)
        {
            using (var conn = new SQLiteConnection($"Data Source={_dbPath};Version=3;"))
            {
                conn.Open();
                string insertSql = @"
                    INSERT INTO VisionData (SN, IsPass, NgReason, DeviceStatus, CollectTime)
                    VALUES (@SN, @IsPass, @NgReason, @DeviceStatus, @CollectTime);";
                using (var cmd = new SQLiteCommand(insertSql, conn))
                {
                    cmd.Parameters.AddWithValue("@SN", result.SN);
                    cmd.Parameters.AddWithValue("@IsPass", result.IsPass);
                    cmd.Parameters.AddWithValue("@NgReason", result.NgReason);
                    cmd.Parameters.AddWithValue("@DeviceStatus", result.DeviceStatus);
                    cmd.Parameters.AddWithValue("@CollectTime", result.CollectTime);
                    cmd.ExecuteNonQuery();
                }
            }
            LogHelper.Info($"数据存入缓存:SN={result.SN}");
        }

        /// <summary>
        /// 补发未上传的数据到MES
        /// </summary>
        public void RetryUpload(MesOpcUaClient opcClient)
        {
            using (var conn = new SQLiteConnection($"Data Source={_dbPath};Version=3;"))
            {
                conn.Open();
                // 查询未上传的数据
                string selectSql = "SELECT * FROM VisionData WHERE IsUploaded = 0 ORDER BY CollectTime;";
                using (var cmd = new SQLiteCommand(selectSql, conn))
                {
                    using (var reader = cmd.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            string sn = reader["SN"].ToString();
                            bool isPass = Convert.ToBoolean(reader["IsPass"]);
                            string ngReason = reader["NgReason"].ToString();
                            string deviceStatus = reader["DeviceStatus"].ToString();
                            int id = Convert.ToInt32(reader["Id"]);

                            // 上传到MES的对应节点
                            bool uploadSn = opcClient.UploadData("ns=2;s=VisionData.SN", sn);
                            bool uploadPass = opcClient.UploadData("ns=2;s=VisionData.IsPass", isPass);
                            bool uploadNg = opcClient.UploadData("ns=2;s=VisionData.NgReason", ngReason);
                            bool uploadStatus = opcClient.UploadData("ns=2;s=VisionData.DeviceStatus", deviceStatus);

                            // 全部上传成功,标记为已上传
                            if (uploadSn && uploadPass && uploadNg && uploadStatus)
                            {
                                string updateSql = "UPDATE VisionData SET IsUploaded = 1 WHERE Id = @Id;";
                                using (var updateCmd = new SQLiteCommand(updateSql, conn))
                                {
                                    updateCmd.Parameters.AddWithValue("@Id", id);
                                    updateCmd.ExecuteNonQuery();
                                }
                                LogHelper.Info($"补发数据成功:SN={sn}");
                            }
                            else
                            {
                                LogHelper.Warn($"补发数据失败:SN={sn},下次重试");
                                break; // 有一条失败就停,避免死循环
                            }
                        }
                    }
                }
            }
        }
    }
}

4. 第四步:UI整合与业务逻辑串联

最后在WinForms里把这些模块串起来,UI不用复杂,放几个Label显示状态,一个TextBox显示日志就行:

using System;
using System.Windows.Forms;

namespace VisionMesBridge
{
    public partial class MainForm : Form
    {
        private VisionDataCollector _visionCollector;
        private MesOpcUaClient _mesOpcClient;
        private SqliteCacheHelper _sqliteHelper;

        public MainForm()
        {
            InitializeComponent();
            // 初始化组件
            _sqliteHelper = new SqliteCacheHelper();
            _sqliteHelper.InitDb();
            _visionCollector = new VisionDataCollector();
            _mesOpcClient = new MesOpcUaClient();

            // 绑定视觉数据回调
            _visionCollector.OnVisionDataReceived += VisionDataReceivedHandler;
        }

        /// <summary>
        /// 视觉数据接收处理
        /// </summary>
        private void VisionDataReceivedHandler(VisionResult result)
        {
            // 跨线程更新UI
            Invoke(new Action(() =>
            {
                lblSN.Text = $"产品SN:{result.SN}";
                lblResult.Text = $"检测结果:{(result.IsPass ? "PASS" : "NG")}";
                lblStatus.Text = $"设备状态:{result.DeviceStatus}";
                txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] 采集到数据:SN={result.SN},结果={(result.IsPass ? "PASS" : "NG")}\r\n");
            }));

            // 先存本地缓存
            _sqliteHelper.SaveToCache(result);

            // 尝试上传MES
            bool uploadSuccess = true;
            uploadSuccess &= _mesOpcClient.UploadData("ns=2;s=VisionData.SN", result.SN);
            uploadSuccess &= _mesOpcClient.UploadData("ns=2;s=VisionData.IsPass", result.IsPass);
            uploadSuccess &= _mesOpcClient.UploadData("ns=2;s=VisionData.NgReason", result.NgReason);
            uploadSuccess &= _mesOpcClient.UploadData("ns=2;s=VisionData.DeviceStatus", result.DeviceStatus);

            if (uploadSuccess)
            {
                // 上传成功,标记缓存数据为已上传(这里简化处理,实际可以在补发时统一标记)
            }
        }

        /// <summary>
        /// 启动按钮点击事件
        /// </summary>
        private void btnStart_Click(object sender, EventArgs e)
        {
            // 连接MES
            bool mesConnected = _mesOpcClient.Connect();
            if (!mesConnected)
            {
                MessageBox.Show("MES连接失败,检查网络和配置!");
                return;
            }

            // 启动视觉采集
            bool visionStarted = _visionCollector.StartCollect();
            if (!visionStarted)
            {
                MessageBox.Show("视觉采集启动失败!");
                _mesOpcClient.Disconnect();
                return;
            }

            // 启动补发线程(后台定期补发)
            var retryThread = new System.Threading.Thread(() =>
            {
                while (true)
                {
                    _sqliteHelper.RetryUpload(_mesOpcClient);
                    System.Threading.Thread.Sleep(10000); // 10分钟补发一次
                }
            }) { IsBackground = true };
            retryThread.Start();

            btnStart.Enabled = false;
            btnStop.Enabled = true;
            txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] 系统启动成功\r\n");
        }

        /// <summary>
        /// 停止按钮点击事件
        /// </summary>
        private void btnStop_Click(object sender, EventArgs e)
        {
            _visionCollector.StopCollect();
            _mesOpcClient.Disconnect();
            btnStart.Enabled = true;
            btnStop.Enabled = false;
            txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] 系统已停止\r\n");
        }
    }
}

四、实战踩坑总结(这些坑我替你们踩过)

坑1:OPC UA证书验证失败

  • 现象:连接MES的OPC UA服务器时,报“Certificate validation failed”;
  • 原因:MES的OPC UA服务器用的是自签名证书,客户端默认不信任;
  • 解决:在OPC UA客户端配置里禁用证书验证(测试环境),或者把MES的证书导入客户端信任列表(生产环境):
    // 在创建ApplicationConfiguration时添加:
    config.SecurityConfiguration.AutoAcceptUntrustedCertificates = true;
    

坑2:视觉SDK调用导致UI卡死

  • 现象:启动采集后,窗体拖不动,按钮点不了;
  • 原因:直接在UI线程调用SDK的阻塞方法;
  • 解决:把采集逻辑放到后台线程(参考VisionDataCollector里的_collectThread),用回调传数据,UI更新用Invoke

坑3:断网后数据补发重复

  • 现象:联网后同一条数据被补发多次;
  • 原因:补发时没加锁,多线程同时处理同一条数据;
  • 解决:给补发方法加lock锁,避免并发问题:
    private readonly object _retryLock = new object();
    public void RetryUpload(MesOpcUaClient opcClient)
    {
        lock (_retryLock)
        {
            // 补发逻辑...
        }
    }
    

坑4:SQLite数据库被占用

  • 现象:程序退出后再启动,报“数据库文件被锁定”;
  • 原因:数据库连接没释放;
  • 解决:所有SQLite操作都用using语句,确保连接自动释放。

五、总结与拓展

这个轻量级上位机的优势就是“小而快”:不用装额外依赖,一个exe就能跑,车间工人也能操作。如果要拓展,还能加这些功能:

  1. 多视觉机支持:把VisionDataCollector改成支持多实例,配置不同IP就行;
  2. 报警功能:设备异常时弹框或发短信通知管理员;
  3. 数据报表:用RDLC做简单的日报/周报,导出Excel;
  4. MQTT对接:如果MES支持MQTT,换成MQTT协议更轻量。

最后提一句:中小工厂的工业互联需求,不一定非要用高大上的框架,C# WinForms+轻量级组件就够了——稳定、易维护才是硬道理。代码里的细节可以根据实际需求调整,有问题评论区交流就行。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐