Sketch 框架和类库 - 第二部分






4.60/5 (3投票s)
为不同的 Arduino 板提供标准接口
引言
在 第1部分 中,我们开发了一个标准的Arduino Sketch框架。现在我们将构建一个.NET类库,它将封装与开发板的所有通信。下载内容包含一个完整的Visual Studio (2015)解决方案,包括一个演示Windows Forms应用程序。演示应用将在第3部分讨论。
背景
在第1部分中,我们开发了一个Sketch框架,该框架响应定义的标准命令,并具有定义的响应格式。它能够返回有关Sketch响应的命令、Sketch提供的值和状态数据以及正在使用的输入和输出引脚的信息。
现在我们将切换到控制计算机端。我们特意使用Windows环境,因此我们使用Visual Studio 2015作为开发环境。
我不会为代码是VB.NET的事实而道歉。在我们开始使用Arduino时,VB仍然是一种比C#稍微更成熟的语言,而且对于那些主要兴趣是编写研究应用程序并发表结果而不是编写精细代码的兼职程序员来说,它也更容易阅读。
当然,最近C#取得了相当大的进展,而且微软也表明这是他们目前的主要关注点,所以实际上我们已经在工作中切换到使用C#了,但这个库仍然是VB.NET。如果你是从来没有用过VB的C#程序员,那么将VB转换为C#可能比一个没有用过C#的VB程序员进行反向转换要容易得多。
好的,那么我们要解决什么问题呢?
如果你需要在Windows PC上通过串行通信与Arduino对话,你会遇到的第一个问题是必须安装相应的串行驱动程序。安装Arduino IDE时,这会自动完成,或者如果你不能、或者不想在机器上安装IDE,也可以手动安装驱动程序。(我们有一些将发往中学的套件,这些PC的桌面被严格锁定——通常OneClick应用程序安装可以正常工作,但在这些情况下安装驱动程序可能是一场噩梦。)
假设你已经安装了驱动程序,接下来的问题是找到Windows分配给你的开发板的虚拟串行端口。 与其手动查找和更改Windows分配的端口号,不如我们第一个要实现的函数是扫描可用端口,并查找名称中包含“Arduino”的端口。通常所有开发板都报告一个形式为“Arduino [boardtype]”的名称。
由于你可能连接了多个开发板,所以我们希望获得所有找到的开发板的列表。
使用代码
在Visual Studio中启动一个类型为ClassLibrary的新项目。将其命名为“ArduinoSerialClassLib
”。
在你的项目中添加一个类型为Class的新项。将其命名为“DetectArduinoClass
”。
我们将使用System.Management命名空间来扫描Windows中的ManagementObjects,以查找名称匹配我们正在寻找的串行端口。
Imports System.Management
你可能需要在“我的项目”下的“引用”部分添加对System.Management的引用。
这个类将有一个类型为Dictionary(Of String, String)
的公共属性,它将包含一个Com端口列表作为项,以及它们的名称作为定义,如果名称与我们正在寻找的匹配。
除了构造函数New()
之外,我们还将有一个函数Rescan()
,如果第一次没有找到任何东西,我们可以调用它,而无需销毁并重新创建对象。我们还可以让它查找不同的名称模式,并将任何匹配项添加到现有字典中。
Public Class DetectArduinoClass
Private _portList As New Dictionary(Of String, String)
Public ReadOnly Property PortList As Dictionary(Of String, String)
Get
Return _portlist
End Get
End Property
Public Sub New(Optional target As String = "Arduino")
ScanPorts(target)
End Sub
Public Sub Rescan(Optional target As String = "Arduino", Optional clear As Boolean = False)
If clear Then _portList.Clear
ScanPorts(target)
End Sub
Private Sub ScanPorts(ByVal target As String)
'target will be matched against the start of the ManagementObject name
Try
Dim searcher As New ManagementObjectSearcher("root\cimv2", "SELECT * FROM Win32_SerialPort")
Dim name, pname As String
For Each queryObj As ManagementObject In searcher.Get()
name = queryObj("Name")
If name.Substring(0, Len(target)) = target Then
pname = queryObj("DeviceID")
_portList.Add(pname,name)
End If
Next
Catch err As ManagementException
'Do something, possibly report err.Message and carry on ...
End Try
End Sub
End Class
就这样。我们现在有了一个属性PortList
,其中包含一个Com端口名称和设备名称的集合。
正如你在构造函数New()和Rescan()中看到的,它们都接受一个可选的字符串参数来匹配名称的开头,所以如果你想查找特定类型的开发板,可以调用New(“Arduino Leonardo”)
或任何其他名称来搜索其他串行端口设备。
Windows的一个奇怪之处在于,如果Arduino在计算机启动时已经连接,那么它就无法从设备读取名称,而只是将其报告为“USB Serial Device”。在这种情况下,你需要断开并重新连接开发板,以便正确读取名称,或者你可以搜索“USB Serial”。你可以修改上面的ScanPorts()
函数,以便在找不到“Arduino”时自动查找“USB Serial Device”。
一旦编译完成,并将DLL作为引用添加到你的主项目中,你就可以简单地包含类似的代码,例如在Form.Load()
中。
Dim det As New DetectArduinoClass()
If det.PortList.Count > 0 Then
'Yay we’ve found at least one board, lets do some stuff
Else
Det.Rescan("USB Serial")
'Have we found one yet ...
End If
别忘了添加对新类库的引用并包含
Imports ArduinoSerialClassLib
在代码的顶部。
好的,现在我们已经确定了哪个端口连接了我们的开发板,让我们向库中添加一个新类来封装与选定开发板的连接和通信。
向项目添加一个名为“ArduinoSerialClass
”的新类。
我们将处理IO端口,所以我们需要导入System.IO.Ports命名空间,并且还要注意何时需要将它们声明为Shared,以便它们可以在不同线程中使用。
Imports System.IO.Ports
Public Class ArdunioSerialClass
Private Shared WithEvents _comPort As New SerialPort
'...
当我们创建ArduinoSerialClass类型的对象时,我们将告诉它使用哪个COM端口,因为我们已经使用DetectArduinoClass对象获取了设备和端口列表。我们也可能想告诉它使用什么波特率,以防我们期望一些非标准的东西,但我们将默认使用115,200波特。
Shared Private _baud As integer
Public Sub New(ByVal pname As String, Optional ByVal baud As Integer = 115200)
ClearProperties()
_baud = baud
If SetupPort(pname) Then
_portFound = True
_comPortName = pname
GetFullStatus()
Else
_portFound = False
_comPortName = ""
End If
End Sub
Private Function SetupPort(pname As String) As Boolean
With _comPort
.BaudRate = _baud
.Parity = Parity.None
.DataBits = 8
.StopBits = StopBits.One
'.Handshake = Handshake.RequestToSend. NB we need None on WIndows 8+
.Handshake = Handshake.None
.DtrEnable = True
.RtsEnable = True
.PortName = pname
.WriteTimeout = 2000 ' Max time to wait for CTS =2sec
.ReadTimeout = 500
.NewLine = chr(10)
.ReadBufferSize = 16384
End With
_comPort.Close()
If Not _comPort.IsOpen Then
Try
_comPort.Open()
Return True
Catch ex As Exception
'Do something about the error .. ex.Message
Return False
End Try
End If
Return False
End Function
好的,我们有私有变量来标记我们是否已连接到串行端口以及端口名称。这些都将作为ReadOnly Public属性可用。
Private Shared _portFound As Boolean = False
Public ReadOnly Property PortFound As Boolean
Get
Return _portFound
End Get
End Property
Private _comPortName As String = "n/a"
Public ReadOnly Property ComPortName As String
Get
Return _comPortName
End Get
End Property
我们将有许多其他公共属性要公开,其中许多将是只读状态报告,但LoopTime、SendSerialValues和SendIntValues将是读写的。
ClearProperties()
将清除任何现有值,以防我们重新扫描,而GetFullStatus()
将发出适当的命令来填充所有命令、状态、值和引脚的定义。
Private Sub ClearProperties()
'clear all public & private properties
_comPortName = ""
_loopTime = -1
_fstatus.Sketch="n/a"
_fstatus.Author="n/a"
_fstatus.CompDate="n/a"
_fstatus.Ver="n/a"
_fstatus.UnitID="n/a"
_cmds.Clear()
_vals.Clear()
_valDesc.Clear()
_stats.Clear()
_statDesc.Clear()
_ins.Clear()
_outs.Clear()
End Sub
Private Sub GetFullStatus()
_comPort.Write(qSketch)
_comPort.Write(qCmdDefn)
_comPort.Write(qStatusDefn)
_comPort.Write(qValDefn)
_comPort.Write(qIns)
_comPort.Write(qOuts)
_comPort.WriteLine(cmdLoopTime)
End Sub
传入的串行数据将完全由一个响应_comPort.DataReceived
事件的私有函数处理。
Private Shared Sub Receiver(ByVal sender As Object, ByVal e As SerialDataReceivedEventArgs) Handles _comPort.DataReceived
Try
Do
Dim gotsome As String = _comPort.ReadLine
'There is a potential problem here if we miss an end of line, this will hang until we get one, or timeout occurs – you might like to fix this by reading byte by byte until you’ve got a complete line or the buffer is empty.
If Not IsNothing(gotsome) Then
ParseRxString(gotsome)
End If
Loop Until (_comPort.BytesToRead = 0)
' Don't return if more bytes have become available in the meantime
Catch ex As Exception 'Typically a TimeoutException
'Handle this error nicely ... ex.Message
End Try
End Sub
因此,我们将有一个函数ParseRxString()
来处理接收数据的完整行。本质上,它将识别响应字符串并将更新相关属性的数据结构。
标准命令和响应字符串是预定义的,但Sketch特定的命令和响应将通过GetFullStatus()
读取,并将适当的数据结构填充为类对象的属性来公开它们。
在查看ParseRxString()
之前,我们需要做两件事。首先,为属性定义数据结构,其次,定义所有标准命令和响应,以匹配我们在第1部分中在Sketch中创建的常量。
属性背后的私有变量需要是Shared的,以便它们可以在跨线程访问——更新响应的是一个DataRecieved事件,该事件可能在与主程序中的类对象实例不同的线程上。
例如,处理获取和设置Arduino上的主程序loopTime:
Public Const cmdLoopTime As Char = Chr(20)
Shared Private _loopTime As integer
Public Property LoopTime As Integer
Get
Return _loopTime
End Get
Set(value As Integer)
_comPort.WriteLine(cmdLoopTime & value)
End Set
End Property
当我们设置新值时,我们不直接更新本地私有变量。我们只是向开发板发出命令,它会响应新值,串行数据接收处理程序将执行本地更新。
同样,对于属性SendSerialValues
和SendIntValues
,它们都是布尔类型。
大多数情况下,我们将使用字典来存储标识符和值或描述的集合。对于Sketch信息,我们将创建一个简单的结构:
Public Structure FirmwareInfo
Dim Sketch As String
Dim Ver As String
Dim Author As String
Dim CompDate As String
Dim UnitID As String
End Structure
Shared Private _fstatus As New FirmwareInfo
Public ReadOnly Property FStatus As FirmwareInfo
Get
Return _fstatus
End Get
End Property
如果我们想更聪明一点,可以将版本字符串解析为Version数据类型,以便应用程序可以轻松地检查所需的最低或最高版本。同样,我们也可以将编译日期字符串解析为DateTime类型——但如前所述,它的格式很糟糕,所以我就留给你来处理了。
这是保存Sketch返回的实际实时值以及它们的描述符的结构定义,这两个都可以被控制程序作为它创建的ArduinoSerialClass对象的属性访问。
Shared Private _vals As New Dictionary(Of String, String) ' this will contain the actual values
Public ReadOnly Property Vals As Dictionary(Of String, String)
Get
Return _vals
End Get
End Property
Shared Private _valDesc As New Dictionary(Of String, String) ' this will contain the descriptions for the values
Public ReadOnly Property ValDesc As Dictionary(Of String, String)
Get
Return _valDesc
End Get
End Property
类似的结构将用于Sketch状态和描述、Sketch响应的命令(除了标准命令之外)以及输入和输出引脚。
现在我们可以看看ParseRxString()
函数的基础。我们将接受一个由一行或多行串行输入组成的字符串。我们将根据任何有效的换行符或回车符将其分解成单独的行。
对于每一行,我们将尝试将第一个字符解析为标识符,第二个字符解析为“=”表示状态字符串跟随,或者“:”表示动态值跟随,字符串的其余部分作为有用部分。
如果它是状态行,那么我们可以查找标准标识符并更新适当的状态属性,或者如果标识符作为键存在于Sketch状态定义字典_stats
中,那么我们就更新那里的值。
对于返回命令、值、引脚或状态定义的标准状态行,形式为“C=L=Enable LED”,然后我们将数据部分“L=Enable LED”进一步分解为键“L”和定义“Enable LED”,并将其添加到有效Sketch命令字典_cmds
中。
Imports System.Text.RegularExpressions
Private Shared Sub ParseRxString(rxstr As String)
Dim lines As String() = rxstr.Split(New String() {"\n\r", "\r", "\n"}, StringSplitOptions.RemoveEmptyEntries)
Dim item As String
Dim ky, op, cont As String
For i = 0 To lines.Count - 1
'remove any remaining control chars from start or end of the line
item = Regex.Replace(lines(i).Trim, "\p{C}+", "")
If Len(item) > 2 Then
'do we have a valid identifier character (ky) and is it a value or a status (op)?
ky = item.Substring(0, 1) ' this be the identifier
op = item.Substring(1, 1) ' this should be either = or :
data = item.Substring(2)
Select Case op
Case "="
'this is a status so do status stuff
Select Case ky
Case lblSendSerialValues
'here we are simply updating the property directly
_sendSerialEnabled = (data="1")
Case lblCmdDesc
'in this case we have to identify the command
'and then either update or add to the dictionary
Dim tmp As String() = data.Split("=")
If _cmds.ContainsKey(tmp(0))
_cmds(tmp(0)) = tmp(1)
Else
_cmds.Add(tmp(0),tmp(1))
End If
'and so on for all the other definition returns...
Case lblSketch
_fstatus.Sketch = data
'and similarly for Version, Date, Author and UnitID...
Case lblLoopTime
Try
_loopTime = CInt(data)
Catch ex As Exception
'ignore it or report the error
End Try
'and likewise for the standard boolean properties SendSerialValues and SendIntValues ...
Case Else
'this should be a sketch specific status so we can update the value in _stats
If _stats.ContainsKey(ky) Then
_stats(ky) = data
Else
'we have an unrecognised status ky so maybe we report it as an error or something...
End If
End Select
Case ":" 'this is a value
If _vals.ContainsKey(ky) Then 'update the value in the dictionary
_vals(ky) = data
Else
'we have an unrecognised value so do something with it ...
End If
Case Else
'this line had an op char that was not = or : - was it a fragment, is it an error?
End Select
Else
'this was some fragment of length less than 2 - is it an error?
End If
Next
End Sub
除了所有这些之外,我们还需要定义所有标识符和命令的常量。下载完整的类库以查看详细信息。
一个Close()
函数可以很好地处理ComPort的销毁,而不是等待垃圾回收,这将很有用,我们还有一个DoCmd()
函数,它将简单地将一个有效命令写入串行端口。这将是控制程序执行Sketch特定命令的主要方式。
Public Sub Close()
_comPort.Close()
_comPort.Dispose()
_portFound = False
_comPortName = ""
End Sub
Public Sub DoCmd(ByVal cmd As String)
' check that we are being send a valid command string
If cmds.ContainsKey(Left(cmd(1)) Then
_comPort.WriteLine(cmd)
End If
End Sub
也可以提供一个SendRawString()
函数来向开发板发送任意数据——但这会打破封装整个东西和让开发板上的Sketch定义它将识别哪些命令的意义。
在下载内容中有一个Visual Studio解决方案,包含两个项目——类库和演示控制程序,它将使用类库来查找连接的Arduino,连接到它并下载其功能,并显示其状态和值。
简要看一下演示应用程序,只有一个Form。当它加载时,它会创建一个DetectArduinoClass对象,如果找到任何开发板,则创建一个ArduinoSerialClass对象来与找到的第一个开发板通信。
然后它会动态创建针对特定开发板的控件,并显示接收到的值。我们将在第3部分中更详细地讨论这一点。
关于轮询和中断事件的说明。
在Arduino开发板上,主程序循环正在轮询输入引脚并报告值。间隔由Sketch中的loopTime
变量定义,该变量可以使用ArduinoSerialClass对象的LoopTime
属性进行设置。
控制程序需要轮询ArduinoSerialClass对象以读取所需的值。如果我们不想错过任何值变化,那么我们必须以至少是Arduino中轮询速率两倍的速率进行轮询——有关背景信息,请参阅Nyquist频率。
在这个简单的演示中,我们使用Forms.Timer对象进行轮询,所以每次Arduino loopTime改变时,我们都会将定时器间隔调整为略小于它的一半。
另一种未包含在此演示中的技术是让ArduinoSerialClass对象在任何我们关心的值发生变化时触发一个事件。这对于缓慢变化的值(例如心率)非常有效,并且意味着我们不需要轮询ArduinoSerialClass中的value属性。它也适用于产生Arduino中断的输入,在这种情况下,我们的loopTime可能很长,但我们希望在检测到触发器时立即响应。
演示应用
示例代码中包含了一个简单的演示应用程序,以说明ArduinoSerialClass的用法。描述、代码示例、屏幕截图和改进建议将构成该系列的第三部分。
关注点
我们创建了一个包含两个对象的类库。第一个扫描Windows管理中列出的可用com端口,并识别与我们正在查找的名称匹配的端口。
找到连接了Arduino的端口后,我们使用另一个对象来处理与开发板的所有通信。它会自动读取开发板的功能,并将属性提供给应用程序。
演示应用程序展示了如何使用这些对象。完整的类库代码还包括应用程序定义特定值以在它们发生变化时引发事件的能力,而不是必须持续轮询它们。
未来发展
你如何使用这个取决于你正在接口的具体设备。显而易见的改进是Sketch和类库中的错误处理——所有这些代码都为了这些文章的目的而被剥离了。
历史
首次发布于2016年7月28日
8月2日更新了代码文件,现在全部经过测试并能正常工作