65.9K
CodeProject 正在变化。 阅读更多。
Home

Spada 通过 Modbus - Arduino 的微 SCADA

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2014年3月29日

CPOL

12分钟阅读

viewsIcon

36702

downloadIcon

2848

Spada via Modbs, Supervisory Control and Data Acquisition with visualb basic,Arduino and firebird

引言

SPADA via Sprotocol 出于同样的目的,我开始了 Spada with Modbus。我曾想改进协议。我当时的想法是“我不能在沙子上建造一座城堡”。因此,我决定改用 Modbus via RTU,这是工业微控制器之间通信的事实标准。我的想法是删除我旧的项目,然后用这个替换。但过了一段时间,我决定也保留 Spada via Sprotocol。为什么?因为它工作原理相同,但在编程方面非常快速和容易。但 Modbus 也是一个标准。因此,如果您决定做一个想要发展并与其他设备、软件等通信的项目,如果您想建造一座城堡,Spada via Modbus 就是解决方案。如果您想要一些快速编码的东西,没有人会看到但同样很好,我认为 Spada with Sprotocol 确实更好。而且它也很适合迈出第一步,因为它使用的是更熟悉的变量,而不是寄存器。

我想提醒一下这个项目的目标

  • 带滞后控制变量(阈值 ON != 阈值 OFF)
  • 带反馈控制变量
  • 通过 PC 设置阈值 On 或 OFF 以及“设定点”运行时间
  • 执行更正
  • 检测本地操作(例如按下按钮,例如:水箱满时或按下按钮时(可能是因为我想排空它)水泵将被激活。我也必须能够从 PC 激活它)
  • 如果手动激活了某项内容,则显示警告
  • 保存到数据库

ModBus

ModBus 背后的原理如图所示:设置变量->信息逻辑->读取变量

Architecture

ModBus 提供一些函数来写入或读取变量。仅此而已。而且它们也不方便。

但最好使用正确的名称,并正确理解这部分,因为这是编程时的主要困难。我花了半天时间,进行了错误的编码。

ModBus 不提供变量,而是提供寄存器或线圈。在 Spada with MODBUS 中,只实现了寄存器,所以我们不讨论线圈(我认为线圈还可以,用于发送命令和一般开关值)。

仔细阅读这部分:一个寄存器由两个字节组成。两个。这意味着如果您使用浮点数或长整型,您必须将其分成不同的寄存器。例如,如果您有一个 4 字节的值,您需要两个寄存器。需要大量的映射工作,因为寄存器大小大于您实际使用的变量量。您需要知道您的变量存储在哪个寄存器中,以及哪个变量存储在寄存器 n 中。我建议参考此链接来解释问题:http://www.chipkin.com/how-real-floating-point-and-32-bit-data-is-encoded-in-modbus-rtu-messages/

ModBus 有一些标准函数。只实现了函数 3 和 16。3 是读取多个寄存器,16 是写入多个寄存器(从 https://codeproject.org.cn/Articles/20929/Simple-Modbus-Protocol-in-C-NET?fid=473146&fr=26 . 复制粘贴而来)。

函数 3 - 读取多个寄存器消息帧

请求

  • 地址(一个字节表示从站 ID)
  • 功能码(一个字节表示功能 ID,在此情况下为“3”)
  • 起始地址(两个字节表示要开始读取的 Modbus 寄存器)
  • 寄存器数量(两个字节表示要读取的寄存器数量)
  • CRC(两个字节包含出站消息的循环冗余校验和)

响应

  • 地址(一个字节包含响应从站的 ID)
  • 功能码(一个字节表示从站响应的功能,在此情况下为“3”)
  • 字节计数(一个字节表示正在读取的字节数量。每个 Modbus 寄存器由 2 个字节组成,因此此值将是 2 * N ,其中 N 是正在读取的寄存器数量)
  • 寄存器值(2 * N 字节表示正在读取的值)
  • CRC(两个字节包含入站消息的 CRC 校验和)

函数 16 - 写入多个寄存器消息帧

请求

  • 地址(一个字节表示从站 ID)
  • 功能码(一个字节表示功能 ID,在此情况下为“16”)
  • 起始地址(两个字节表示要写入的 Modbus 寄存器)
  • 寄存器数量(两个字节表示要写入的寄存器数量)
  • 字节计数(一个字节表示正在写入的字节数量。每个 Modbus 寄存器由 2 个字节组成,因此此值将是 2 * N,其中 N 是正在写入的寄存器数量)
  • 寄存器值(包含要写入的实际字节的 2 * N 字节)
  • CRC(两个字节包含出站消息的循环冗余校验和)

响应

  • 地址(一个字节包含响应从站的 ID)
  • 功能码(一个字节表示正在响应的功能,在此情况下为“16”)
  • 起始地址(两个字节表示第一个写入的起始寄存器地址)
  • 寄存器数量(两个字节表示已写入的 Modbus 寄存器数量)
  • CRC(两个字节表示入站消息的 CRC 校验和)

令我惊讶的是,读取响应中缺少地址。实际上,我们获取了值,但不知道它们来自哪里。幸运的是,我们可以在一次读取中读取整个寄存器。(我无法想象为什么有人需要单独读取)。我们假设我们获得的每次读取响应都是一个完整的寄存器扫描。这一事实也意味着每次回答都必须遵循一个问题。

毕竟,还有扫描整个寄存器并根据需要重建多字节变量的工作。这就是 ModBus 的困难之处。

Arduino

ModBus 只是一个通信协议。SCADA 系统围绕被监控的系统构建。在某个点,您必须识别变量的性质。因此,当您获取 ModBus-Slave sketch(1.0 是最新的)时,在众多选项中,您唯一感兴趣的是这部分。

   
     /* slave registers example*/

//It is uncomfortable a slave register, better a variable register, and than 
    enum {        
            MB_REG0,
            MB_REG1,
            MB_REGS         /* total number of registers on slave */
    };

    int regs[MB_REGS];    /* this is the slave's modbus data map CHANGE ONLY THE INDEX IF YOU USE OTHER NAME IN THE ENUM-- */

    void setup() 
    {
            /* Modbus setup example, the master must use the same COM parameters */
            /* 115200 bps, 8N1, two-device network */
            configure_mb_slave(115200, 'n', 0);
    }


    void loop() 
    {
            /* This is all for the Modbus slave */
        update_mb_slave(MB_SLAVE, regs, MB_REGS);

        /* your code goes here */
    }

如何处理这些东西?

映射:为变量枚举

 enum variables{
                        SYSTEM, //1 Register -reserved - position in reg= 0
                      LIGHT_ON, //1 Register  -    threshold on - position in reg=1,2
                      LIGHT_OFF,//1 Register  -    threshold off
                      LIGHT_READ,//1 Register- Light analog read pin A03
                      L1,//1 Register - Light read
                       L1_MANUALLY,//1 Register -  warning if L1 is activated manually 
                      PRESSURE_ON,//1 Register 
                      PRESSURE_OFF, //1 Register 
                      PRESSURE_SETPOINT,//1 Register -
                      DELAY, //2 Register- not more used... but leaved for example
                        SAVE,//1 Register
                      LOAD,//1 Register
                    RESET_EEPROM,//1 Register
                      
                  
                  //(registers count until here =14)
                  VARIABLES_COUNT
                  };

    #define REGISTER_COUNT 14

但这还不够,您必须映射寄存器,以便数组的 [YOUR_VARIABLE_NAME>] 位置会返回其在寄存器中的位置(第一列)以及寄存器数量(第二列)。

                //
                int map_Address[VARIABLES_COUNT][2]={{0,1},
                                        {1,1},
                                        {2,1},
                                        {3,1},
                                        {4,1},
                                        {5,1},
                                        {6,1},
                                        {7,1},
                                        {8,1},
                                        {9,2},//here is 2 bytes because I want reserve a long
                                        {11,1},
                                        {12,1} ,
                                        {13,1}  
                                        };

现在我需要在我的代码中使用内部方法来从控制器内部读取和写入。函数 3 和 16 从外部工作。但内部呢?因此,有两个函数,由于它们经常使用,所以我使用了非常简短的名称。

 
        long V(int variable) //return the value of the desired variable, max 4 byte
    {
        long returnValue;
        if (map_Address[variable][1]=1) 
            return regs[map_Address[variable][0]];
        else 
        return regs[map_Address[variable][0]] << 16 +
                regs[map_Address[variable][0]+1];
            

    }

    void writeV(int variable, long value)//set my register
    {
        if (map_Address[variable][1]=1) 
          regs[map_Address[variable][0]]=value;
        else 
        {
            int bHI, bLOW;
            bHI=value>>16;
            bLOW=value & 0XFFFF;
            regs[map_Address[variable][0]] =bHI;
            regs[map_Address[variable][0]+1] =bLOW;
        }
    }

现在您可以运行您的 setup 和 loop。例如,我的 loop 是

 //note how I utilize just variables until the very end
    void loop() 
    {
            /* check for master requests- This is MODBUS*/
         update_mb_slave(MB_SLAVE, regs, VARIABLES_COUNT);
     
     //FROM HERE BEGIN YOUR CODE
      writeV(LIGHT_READ,analogRead(0)); //read the light sensor and store in the register, available to enquire
        
        //catch the push of the button
      if (digitalRead(3)&& millis()-t_bounce>200  ) 
        {
          t_bounce=millis();
          writeV(L1,V(L1) ^ 1) ;     //turn on/off the led
          writeV(L1_MANUALLY,V(L1_MANUALLY) ^ 1); //activate/deactivate the warning manually activation
       }
     
      
     //process the variables
      if(V(L1_MANUALLY)) //if manually do what we store
       {
         digitalWrite(2,V(L1));
       }
     else
      {// else look if the actual read is bigger or smaller than the thresholds 
         if ( V(LIGHT_READ)>V(LIGHT_OFF) )
           {
            digitalWrite(2, LOW);
            writeV(L1,0);
         }  
         else
         {     if (V(LIGHT_READ)< V(LIGHT_ON))
                     {
                        digitalWrite(2, HIGH);
                        writeV(L1,1);
                      } 
                      else
                      {
                        digitalWrite(2, V(L1));
                      }
         }
       }
    }

Visual Basic

控制器已设置好。它会做好它的工作,并准备好回答我们提出的任何问题。

我们必须在我们的 Master 中实现 Master 的 Read 和 Write 功能。我将 Protocol from distantcity 从 C# 翻译成 VB.NET 寄存器,https://codeproject.org.cn/Articles/20929/Simple-Modbus-Protocol-in-C-NET?fid=473146&fr=26 .

 

我只做了一些小的改动

 

  • Modbus 继承了 SerialPort。
  • CRC 函数是从 Arduino sketch 翻译过来的。编码函数=解码函数。
  • 我捕获了 DataReceived 事件。在那里我重组了 token,并通过一个新事件 MessageRecived(b() as byte) 发送出去。

重要提示:您可以捕获 MessageRecived,但您必须调用另一个函数。

那么它是如何工作的

                Private WithEvents com As New Modbus_S.Modbus 'It declares the variable com that is inherited from SerialPort  
                ...
                
                ...
                'For Open we need the port number, the baud rate, the number of bits, the parity space and the bit stop                        
                com.Connect(tscbCOMList.Text, com.BaudRate, 8, IO.Ports.Parity.Space, IO.Ports.StopBits.One)

重要提示:奇偶校验字节未正确设置。理论上应该是偶数,但不起作用。它可以使用 Space 工作。

现在准备好发送我们的函数 3 和函数 16 了。

    ... ' reads from slave 1 the registers from 0 to 13
        com.SendFc3(1, 0, 13)            
    ...
         'writes on slave 1 the register of the variable Light_on, the count of bytes and the values()
         com.SendFc16(1, map_Variables(variables.LIGHT_ON, 0), map_Variables(variables.LIGHT_ON, 1), {CUInt(nuOn.Value)})

Arduino 将会响应,com 将重建消息并发送一个新事件,我们可以从类外部捕获该事件。

         Private Sub com_MessageReceive(b() As Byte) Handles com.MessageRecived
            Invoke(New MsgReceive(AddressOf ProcessMessage), b)
        End Sub
        ...
         Private Sub ProcessMessage(b() As Byte)
         'read the message b() and make stuff
         End Sub

我认为这就是全部了。

图形界面

我对图形进行了一些概念性的修正。即使看起来不自然,正确的轨迹也是从右到左。这是解释。我认为任何使用过示波器的人都不会有问题。

基类来自 Carl Morey

https://codeproject.org.cn/Articles/42557/Arduino-with-Visual-Basic

我做了很多改进。现在完全可调整大小,并允许在同一图形中绘制更多变量。

如何使用它

在其他地方声明一个普通的 PictureBox 控件,然后声明一个变量,例如

dim dImg as Display   
    dImg = New Display(Me.PictureBox1.Width, Me.PictureBox1.Height) 
            Me.PictureBox1.Image = dImg.GetImage 'dImg will create a Image and attach to it

每行都需要自己的缓冲区。因此,我们必须通过以下方式传递这些信息:

       dImg.AddVariable(variables.LIGHT_READ, Color.Lime , "Light Read")

此时,图形已准备好接受值。

 dImg.NewPoint(variables.LIGHT_OFF, Now(), value) 'In order, the index,the time and the value
    'at end of the insertions remember to call again:
                    Me.PictureBox1.Image=dImg.GetImage

数据库

数据库是FIREBIRD。

我认为没有比这更适合此目的的数据库了。嵌入式(无需安装任何东西)、免费、功能极其强大,而且通常会让您说:“uff”的特性,比如大小写敏感性,在处理数字时却不会令人烦恼。所以让我们享受这个令人难以置信的强大数据库。

GUI

GUI 只是一个文本框,用于编写选择、插入、更新和删除的查询。

正如我所说,图形将读取 datatable(第一列是时间,请记住),并绘制相对图形。

该程序的受众是具备编程技能的合格人员。我认为“与”程序通过 SQL“交谈”不是问题。更好的是,它非常强大。您可以进行任何类型的设置。

下面我将向您展示一些重要的查询,例如图片中的查询,它允许您重建给定时刻的元组。并确定某个时刻的整个系统。

功能包括

1)查询:将 SQL 指令插入文本框

  Select trim(name),qryString from queries 

此指令调用 `queries` 表,您可以在其中选择已保存的命令,进行复制粘贴准备执行。

2) 运行:它执行文本框中的 SQL 指令

3) 引用查询:它准备好文本框中的文本,准备好插入数据库,带有引号和“INSERT”子句。例如,您写入

Select t , val as Pressure  from dates   

如果您单击引用查询,您的框中将出现

 insert into queries(name,qrystring)
    values('NAME HERE',
    'Select t , val as Pressure  from dates ;'); 

您需要在指示的地方放置“名称”,然后单击运行

4) 备份:它会将您的数据库备份到 .sql 文件:它只是一个包含 SQL 指令(如 CREATE TABLE...bla bla)以重建您的数据库的文本文件。如果您打开此文件,您可能会因为开头有许多奇怪的指令而感到恐惧。您可以在我的网站上找到解释。它会备份整个数据库。

5)导出到 Excel:是的,它会导出到 Excel。但仅限于文本框中的查询(无需运行)。

SQL 入门

如果您想开始编写查询,您必须了解结构。

只有三张表,而且它们极其简单。两张用于数据记录,第三张用于存储查询。

重要提示:Firebird 没有自增值。但它通过触发器和生成器工作。这里有相关信息。表 `variables` 和 `queries` 有一个 `i` 列,并带有用于自动增量的触发器。备份也将包含这些指令。

 CREATE TABLE variables ( 
        i  INTEGER   NOT NULL, -- this is a value, where is associated a trigger 
        name  VARCHAR(255)  ,
        description BLOB SUB_TYPE 1 ,
        um  VARCHAR(5)  ,
        CONSTRAINT  PRIMARY KEY(i)
    );

Variables 包含变量的描述性定义...例如,名称和描述。它有一个与 `i` 关联的触发器,它也是其主键(如果您设置正确,`i` 将与您发送给 Arduino 以请求其寄存器的值相同)。

CREATE TABLE dates ( 
        var  INTEGER   NOT NULL,
        t  TIMESTAMP   NOT NULL,
        val  BIGINT  ,
        CONSTRAINT  PRIMARY KEY(var,t)
    ); 
    CREATE INDEX ixVal on table dates(val);

Dates 是工作范围,包含来自 Arduino 的元组。`var` 是指向 `Variables` 表的 `i` 的外键。`T` 是时间戳。`val` 是值。

Dates 已完全索引。主键由列(`var`、`t`)组成,还有一个关于 `val` 的附加索引。

Dates 可能会变得非常大。这些索引很重要。

还有一个名为 `queries` 的表存储我们的 SQL 指令。

CREATE TABLE queries (
    i  INTEGER   NOT NULL,
    name  VARCHAR(255)  ,
    qrystring BLOB SUB_TYPE 1 ,
    CONSTRAINT PRIMARY KEY(i)
    );   

深入学习 SQL

重要的是同时查看所有变量的所有图形,以识别原因和结果之间的相关性,或者您的系统何时启动,发生错误时。因此,我们需要所有变量的元组。变量的数量可能非常大,无法在开发时确定。我没有在表中添加更多列,而是只添加了一个列,但每个元组都具有相同的插入时间。实际上,它就像系统在同一毫秒内同时插入所有变量一样。(VB 做到了。在源代码中已注释)。

重建元组的查询如下:

    SELECT a.T, a.VAL as Light_on,b.val as Light_off , c.val as Light_read,(500*d.val) as L1,e.val as pressure_on,
    f.val as pressure_off, g.val as pressure_setpoint
    FROM DATES a
    inner join DATES B on a.t=b.t
    inner join DATES C on a.t=c.t
    inner join DATES D on a.t=d.t
    inner join DATES E on a.t=e.t
    inner join DATES F on a.t=f.t
    inner join DATES G on a.t=g.t
    where  a.t between '23.03.2014 14:00:00' and '23.03.2014 18:00:00' 
    AND a.var = 1
    AND b.var = 2
    AND C.var = 3
    AND D.var = 4
    AND E.var =5
    AND F.var=6
    AND G.var=7  
    order by a.t;

实际上,我根据要显示的变量数量对 `dates` 表进行了如此多的连接。

技巧

  • 为列设置别名,您将在图例中找到它们。
  • 要缩放图形,请在 select 中乘以一个因子(对于 1/0 的值很有用)。

连接条件中的严格“=”以及索引允许我们获得良好的响应时间。

使用下面的块也可以做同样的事情。

--SET TERM !!; -- deactivate the comments with other GUI's Ex: Flamerobin
    EXECUTE BLOCK  --ix=    1         2         3       4        5         6      7  ==>11111110b =254d
    returns(  t timestamp,a bigint,b bigint,c bigint, d bigint,e bigint,f bigint,g bigint,n integer)
    AS  
         
        declare tmpt2 timestamp; 
        declare tmpVar integer;
        declare tmpVar2 integer;
        declare tmpVal bigint;
        declare tmpVal2 bigint;
        declare ctrlFlag integer;
        DECLARE cur cursor FOR  
             /*********************************************************/
            /***         MODIFY THE QUERY                           **/       
           /*********************************************************/
            (SELECT var,t, val from dates 
            where t> '22.03.2014 00:00:00' --between '23.03.2014 00:00:00' and '24.03.2014 15:00:00' 
            order by t,var ); --important, order first for time than for var
        BEGIN
        n=0;
        ctrlFlag=254;  
        OPEN cur; 
            fetch cur INTO tmpVar,t,tmpVal;
            IF (ROW_COUNT = 0) THEN exit; 
            while (1=1) do 
        BEGIN   
            ctrlFlag=bin_xor(ctrlFlag,bin_shl(1,tmpVar));
          
            fetch cur INTO tmpVar2,tmpt2,tmpVal2; 
              IF (ROW_COUNT = 0)   THEN leave;  
           
               IF (tmpVar=1) THEN a=tmpVal;
                IF (tmpVar=2) THEN b=tmpVal;
                IF (tmpVar=3) THEN c=tmpVal;
                IF (tmpVar=4) THEN d=tmpVal;
                IF (tmpVar=5) THEN e=tmpVal;
                IF (tmpVar=6) THEN f=tmpVal;
                IF (tmpVar=7) THEN g=tmpVal;
            if (tmpt2<>t ) then
            BEGIN
                
                if (ctrlFlag=0) then 
                begin
                    n=n+1;
                    suspend;
                end
                ctrlFlag=254;
            
            END
            tmpvar=tmpVar2;
             t=tmpt2;
            tmpVal=tmpVal2;

            END 
                if (ctrlFlag=0) then 
                begin
                    n=n+1;
                    suspend;
                end
    close cur; 
    END
    --SET TERM; !! -- deactivate the comments with GUI Ex: Flamerobin

当我说到 Firebird 的强大功能时,这是一个例子。Firebird 支持完整而强大的过程语言。通过它可以做很多事情。这是一个 EXECUTE BLOCK。它允许在不保存任何过程的情况下执行一系列指令。MySQL 不支持它。此块比 SELECT 稍快:SELECT 为 0.160 毫秒,BLOCK 为 0.120 毫秒,查询包含 1700 个元素/列(约 11900 个元素)。

也许我需要花一些时间来解释 `ctrlFlag`。

使用元组,需要检查所有变量是否都已设置、保存或已保存。每个变量都有一个索引。如果我在所需变量的位置放置一个标志 1,我可以正确地激活或停用它的标志。例如:

  系统 灯亮 灯灭 灯读取 L1 压力开启 P_关闭 P_设定点
index 0 1 2 3 4 5 6 7
  0 1 1 1 1 1 1 1

我没有要求系统,所以它是 0。我要求所有其他都是 1。

它输出值为 11111110b=254。

要将索引的值设为零,我执行 `ctrlFlag`=ctrlFlag XOR (1<<index)。此指令将仅关闭其自身的位。

最后,如果所有值都设置为零,`ctrlFlag` 将等于 0。

使用程序

它专门针对一个应用程序,目的是理解如何控制系统,它没有针对速度或设置进行优化。

正是出于同样的目的,如果您想学习如何控制系统,步骤如下:

  • 连接(将连接您,但不会开始消息传递)
  • 开始消息,将启动消息传递(如果不起作用,请多次单击)

它将向控制器发送命令并请求发送报告。

关机前

  • 停止消息,它将停止控制器发送消息。
  • 现在您可以安全地断开连接(类似于 USB :-))

主要困难在于控制器和 PC 之间通信开始时的正确同步。这带来了一些我必须解决的麻烦。

架构

© . All rights reserved.