基于服务的 Arduino 数据记录器






4.94/5 (13投票s)
使用 WCF Windows 服务实现的 Arduino 数据记录器
Content
1. 引言
如果您搜索开源电子产品,您很可能会首先遇到Arduino [1]。最重要的一点是,它几乎无缝地结合了硬件和软件。您可以从现实世界收集数据,并根据您的Arduino相应地更改物理环境中的某些内容,同时它让您摆脱了许多硬件问题,而许多其他问题都可以通过兼容的扩展板(Shields) [2] 来解决。因此,它在全球范围内获得了极大的普及。
收集数据是基本用途。您可以检测按钮是否被按下。您可以测量亮度、温度、湿度或距离。您可以检测运动或振动。任何您想到的测量都可以通过Arduino找到方法。
数据收集之后是数据到IT基础设施的传递。您可以通过多种方式完成这项任务。基本上,您可以通过计算机的串行端口与Arduino通信。除了串行通信,您还可以通过以太网、WiFi或GPRS传输数据包,但需要使用适当的Shields。
2. 概述
本项目涉及从一组Arduino板收集数据并将其存储在数据库中。但使该项目有趣的是,它是一个基于Windows Communication Foundation (WCF) Windows服务的无人值守系统 [3]。
项目中存在两个不同的WCF服务。第一个是用于数据管理的,它利用Representational State Transfer (REST) [12] 与其客户端进行通信。第二个服务用于Arduino板卡管理,它依赖于数据服务作为数据源。它从数据服务检索板卡和引脚信息,将它们发送到板卡,接收来自板卡的引脚读数,最后将这些读数转发给数据服务。
我使用SQL Server数据库作为数据服务后面的实际存储。但是,存储逻辑和服务的实现是分开的。Managed Extensibility Framework 2.0 (MEF) [6] 在创建服务实例时将它们连接到数据服务中。
作为O/R映射器,使用了Entity Framework 6.1 (EF) [7]。在数据库中设计和创建数据表后,我利用数据库优先的方法创建了相应的类。虽然需要一些操作,但Entity Framework是将数据库映射到项目中的一个非常好的工具。
该项目的Arduino端比较简单。它监听配置信息,然后开始通过通信通道发送读数。我为本项目实现了串行接口通信,但通过适当的硬件也可以使用以太网或其他数据传输基础设施。
3. 背景
3.1 什么是WCF?
简单来说,Windows Communication Foundation是微软的服务导向架构的解决方案 [9]。它允许您创建孤立的计算资源。当您想从系统外部的某个资源检索信息时,您基本上需要知道
- 从哪里检索(地址、URI是什么?)
- 如何与之交谈(通信机制是什么?TCP、HTTP、文本、二进制,什么格式?) [10]
- 它提供什么(功能是什么?)
对于一个服务,这些问题被打包到WCF的端点概念中。如果您创建一个WCF服务,您需要在配置的端点中指定一个地址(哪里)、绑定(如何)和契约(什么)。因此,任何客户端只要知道您的服务的端点,就可以使用您的服务。 [11]
REST
Representational State Transfer (REST) 是绑定问题的答案 [4][5]。它直接使用HTTP请求(例如,GET
、PUT
、POST
、DELETE
等)来交换信息。当您将端点的绑定设置为REST时,意味着服务期望客户端使用纯HTTP语言进行通信。此外,RESTful服务将其功能暴露为与URI关联的资源(对象)。您可以直接对资源执行CRUD操作,因为CRUD函数与HTTP请求具有一对一的关系。
托管服务
您的服务需要一个“居住”的地方。有几种方法可以做到。您可以要么使用现成的解决方案,要么设计自己的托管程序。然而,当有成熟的解决方案时,使用它们是一个好习惯。如果我不需要在互联网上公开我的服务,我通常会选择Windows服务作为托管程序。您可以使用“installutil.exe”实用工具将您的服务作为Windows服务安装在计算机上 [13]。
3.2 什么是EF?
Entity Framework (EF) 是一个对象/关系映射框架,用于将数据库表及其关系映射到代码端。基本上,您的表会转换为相应的类,并且表之间的关系会反映在生成的代码中。对于EF,这个断言不一定正确,因为反之亦然。EF可以获取您的类并将其转换为数据库表。EF中有三种映射数据库和类的方法。
- 您可以先设计数据库,然后生成类(数据库优先的方法)
- 您可以先设计对象类,然后生成数据库(代码优先)
- 或者使用EF提供的设计器来创建数据模型(模型优先)
只要您知道自己在做什么,所有这些方法都能很好地工作。代码优先的方法需要更多时间,因为您需要手动编写所有代码。在我看来,使用设计器是一种更优雅的方式,但需要对生成的代码进行更多的工作才能将其适应您的整体软件设计 [14]。
3.3 什么是MEF?
好吧,实际上,关于MEF是什么,存在很大的争论。:) 然而,我至少可以告诉您它做什么。它允许您消除类之间的硬编码依赖关系,并且完美匹配SOLID设计方法中的“依赖倒置原则” [15] [16]。
MEF的关键点
- 功能(契约)
- 提供该功能的提供者(部件)
- 需要该功能的客户端(部件)
- 搜索候选提供者的位置(目录)
- 部件的管理(组合容器)
当所有组件正确组合在一起时,MEF就能完成它的工作,也就是连接部件。因此,客户端代码就可以获得它所需的功能。
所以呢?这种方法为您的设计增加了强大的功能。请参考SOLID设计原则,因为我认为这是对软件应具备样子的纯粹解释。CP上有很多关于该主题的优秀文章。例如,在本项目中,您可以通过替换包含数据库访问代码的程序集来轻松地将当前数据存储与数据服务分离。
4. 设计与实现
4.1 数据库设计
ARD_BOARD
存储Arduino板卡信息。重要的一点是,F_CONN_PROVIDER
存储每个板卡连接提供者类的程序集限定名称,而F_CONN_STR
是相应提供者的设置。提供者在运行时通过反射根据其限定名称进行实例化。
ARD_PIN
存储板卡的引脚信息。每个[FK_ARD_BOARD, F_PINNUM, F_IS_ANALOG]
三元组对应于特定板卡上的特定引脚。当板卡连接时,其引脚信息会被发送到板卡。然后,板卡开始根据F_READ_PER
(指定读数之间的时间间隔)从这些引脚读取数据。
READING
顾名思义,用于存储引脚读数。F_VALUE
对于数字引脚可以是0或1,对于模拟引脚则在0-1023之间。
我使用EF数据库优先的方法生成了数据库表的相应类。生成后,我编辑了模型以区分数字和模拟引脚。F_IS_ANALOG
列是区分的标识符。这在服务实现中并不重要,但在数据编辑器方面,该模型将允许我设计得更清晰。
4.2 WCF数据服务 (RESTful)
数据服务功能由ArdaDataSvc
提供,该服务使用“installutil.exe”注册并通过Windows服务托管。当它由Windows服务宿主启动时,它会创建一个WebServiceHost
对象(host
)来提供RESTful通信。对于每个服务请求,宿主都会创建一个服务库实例(ArdaDataSvcLib
)来暴露底层数据。该实例通过Managed Extensibility Framework 2.0 (MEF)连接到实际存储。在本项目中,我使用了MS SQL数据库作为数据存储,它首先由ArdalogEntities
代理,然后由SvcDataSource
代理。ArdalogEntities
是Entity Framework 6.1 (EF)的输出,而SvcDataSource
使用ArdalogEntities
进行数据库操作。
IArdalogSvc
是服务端类和存储端类之间约定的接口。如果您想更改存储,唯一需要做的就是创建一个实现IArdalogSvc
的类。MEF会在运行时搜索IArdalogSvc
并将其连接到ArdaDataSvcLib
实例。IArdalogSvc
也是数据服务暴露的契约。服务客户端在通过通道与服务通信时使用此接口。
// File: IArdalogSvc.cs
[ServiceContract]
public interface IArdalogSvc
{
[OperationContract]
[WebGet(UriTemplate="boards")]
List<Board> GetBoards();
[OperationContract]
[WebGet(UriTemplate="boards/{bid}/pins")]
List<Pin> GetPins(string bid);
[OperationContract]
[WebInvoke(UriTemplate="readings", Method="POST")]
void PostReading(Reading reading);
}
ArdaDataSvc
在其OnStart
方法中创建一个Web服务宿主,以响应服务调用的REST请求。
// File: ArdaDataSvc.cs
/// <summary>
/// Ardalog data service (WCF Windows service + REST)
/// </summary>
public partial class ArdaDataSvc : ServiceBase
{
// base address of service (can also be specified in app.config)
static string _baseaddr = @"https://:45556/ArdalogSvc";
// service host for REST
WebServiceHost _host = null;
protected override void OnStart(string[] args)
{
try
{
if (_host != null)
_host.Close();
_host = new WebServiceHost(typeof(ArdaDataSvcLib), new Uri(_baseaddr));
_host.Open();
_host.Faulted += HostFaulted;
}
catch (Exception ex)
{
TextLogger.LogIt(ex);
throw;
}
}
// other code
以下代码片段展示了MEF如何协同工作以组合相关部件。
ArdaDataSvcLib
告诉MEF它将[Import]
DataSource
,即IArdalogSvc
。
// File: ArdaDataSvcLib.cs
/// <summary>
/// Data service library class
/// </summary>
public class ArdaDataSvcLib : IArdalogSvc
{
public static event EventHandler<EventArgs> Created;
public ArdaDataSvcLib()
{
if (Created != null)
Created(this, new EventArgs());
}
/// <summary>
/// Underlying data source which is filled by MEF
/// </summary>
[Import]
public IArdalogSvc DataSource { get; set; }
// other codes
SvcDataSource
告诉MEF它[Export(typeof(IArdalogSvc))]
。
// File: SvcDataSource.cs
[Export(typeof(IArdalogSvc))]
public class SvcDataSource : IArdalogSvc
{
// other codes
ArdaDataSvc
在创建ArdaDataSvcLib
实例时(ServiceInstanceCreated
),在Created
事件处理程序中利用MEF来组合部件。在处理程序方法中,使用程序集文件夹创建DirectoryCatalog
。CompositionContainer
使用该目录来搜索部件。如果一切顺利,MEF将为ArdaDataSvcLib
实例(lib
)组合部件。
// File: ArdaDataSvc.cs
public ArdaDataSvc()
{
// some other code
ArdaDataSvcLib.Created += ServiceInstanceCreated;
}
/// <summary>
/// ArdaDataSvcLib.Created handler.
/// MEF combines exports & imports for ArdaDataSvcLib when an instance created.
/// </summary>
/// <param name="o"></param>
/// <param name="e"></param>
static void ServiceInstanceCreated(object o, EventArgs e)
{
// get the assembly's directory.
var dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
// create a directory catalog for MEF
using (var catalog = new DirectoryCatalog(dir))
{
// create a composition container with that catalog
using (var container = new CompositionContainer(catalog))
{
ArdaDataSvcLib lib = o as ArdaDataSvcLib;
if (lib == null)
throw new ArgumentException("service library is not ready");
// compose related parts
container.ComposeParts(lib);
}
}
}
// other codes
请注意,数据库创建脚本(DbCreate.txt)和示例数据(TestData.txt)包含在Ardalog2.zip/ArdaDAL目录中。
4.3 WCF板卡管理服务
ArdaListenerSvc
是Windows服务托管的服务。启动时,它会初始化BoardListenerManager
单例实例。在初始化过程中,BoardListenerManager
从数据服务检索板卡列表,并创建所有板卡监听器及其通信提供者。CommProviderBase
负责与Arduino板卡保持实时连接。建立连接后,BoardListener
实例将板卡ID和引脚信息发送到Arduino,使其开始进行引脚读数。每个BoardListener
会跟踪来自其通信提供者的消息,并据此采取行动。例如,如果传入消息是引脚读数,则BoardListener
会将该信息转发给数据服务。
建立连接后,BoardListener
通过CommProviderBase
将配置信息发送到板卡。以下代码片段显示了初始化逻辑。
// File: BoardListenerManager.cs
/// <summary>
/// initializes internal structures.
/// call this when the service is started
/// </summary>
public void Initialize()
{
// some cleanup code here
// ...
// open data service channel factory
this._channelFactory.Open();
// create a communication channel
var channel = this.GetNewChannel();
// get boards
var boards = channel.GetBoards();
// create listeners for boards
foreach (var b in boards)
{
if (string.IsNullOrEmpty(b.ConnectionProvider)
|| string.IsNullOrEmpty(b.ConnectionString))
{
TextLogger.LogIt("board skipped due to lack of conn info ({0})", b.Id);
continue;
}
var p = CreateCommProvider(b);
this._listeners.Add(b.Name, new BoardListener(b, p));
}
}
/// <summary>
/// creates a new communication channel to the data service
/// </summary>
/// <returns></returns>
public IArdalogSvc GetNewChannel()
{
return this._channelFactory.CreateChannel();
}
/// <summary>
/// finds & creates communication provider for a board.
/// it may be a serial interface, Ethernet, or WiFi.
/// in this project, only serial port communication is implemented
/// </summary>
/// <param name="b"></param>
/// <returns></returns>
private static CommProviderBase CreateCommProvider(Board b)
{
var t = Type.GetType(b.ConnectionProvider);
return (CommProviderBase)Activator.CreateInstance(t);
}
// some other code
以及板卡监听器部分
// File: BoardListener.cs
public BoardListener(Board board, CommProviderBase comm)
{
this._board = board;
// get pins of the board from data service
this._pins = BoardListenerManager.Instance
.GetNewChannel().GetPins(this._board.Id.ToString());
if (this._pins.Count <= 0)
{
TextLogger.LogIt("no pin defined for board: ({0})", this._board.Id);
return;
}
this._commProvider = comm;
this._commProvider.BoardMessageReceived += MessageReceivedHandlerAsync;
this._commProvider.CommStateChanged += CommStateChangedHandlerAsync;
// initialize comm provider with board id and connection info for the board.
this._commProvider.Initialize(this._board.Id, this._board.ConnectionString);
}
/// <summary>
/// executed when the state of communication changed.
/// when communication established, it send configuration info to the board.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
async void CommStateChangedHandlerAsync(object sender, StateChangedEventArgs e)
{
await Task.Run(() =>
{
try
{
switch (e.BoardState)
{
case CommState.Closed:
break;
case CommState.Open:
this._commProvider.Send(string.Format("id,{0}$", this._board.Id));
this._pins.ForEach(p1 =>
{
this._commProvider.Send(string.Format("addpin,{0},{1},{2},{3}$",
p1.Id, p1.PinNum, p1.IsAnalog ? "1" : "0", p1.ReadPeriod));
});
break;
case CommState.Aborted:
break;
default:
break;
}
}
catch (Exception ex)
{
TextLogger.LogIt(ex);
}
});
}
// some other code
4.4 Arduino板卡
Comm
是实现Arduino端通信协议的类,而SerialComm
是Comm
的串行端口派生类。当板卡从板卡管理服务接收到addpin
命令时,它会根据参数创建(或更新现有)引脚定义(PinDef
),然后开始监视它。每次读数都通过Comm
的sendReading
函数发送(在这种情况下是SerialComm
)。如果您使用以太网Shield作为传输机制,则应实现Comm
的以太网派生类并将其上传到Arduino板卡。
/* File: ArdalogBoard.ino */
// board id from database
int boardId;
// communication handler
Comm* comm;
// pin list constructed according to database
PinDefList pinlist;
// arduino setup
void setup()
{
comm = SerialComm::getInstance();
comm->setIdCallback(setId);
comm->addPinCallback(addPin);
comm->initialize();
}
// arduino main loop
void loop()
{
// Serial handler update (to read serial data)
comm->update();
// scheduler update (to read pin and send data)
Scheduler::getInstance()->update();
}
// callback for board id command
void setId(int id) {
boardId = id;
}
// callback for add pin command
void addPin(int id, int pinnum, bool isanalog, int period) {
PinDef *d = pinlist.Find(id);
if(d == NULL) {
d = new PinDef(id, pinnum, isanalog, period);
d->setReadingCallback(sendReading);
pinlist.Push(d);
}
else {
d->setPeriod(period);
}
}
// callback for pin reading
// sends value from pin to service
void sendReading(int id, int val) {
comm->sendReading(id, val);
}
在setup
中,设置了通信处理程序(comm
)。目前我们只有串行端口通信实现(SerialComm
),并将其用作数据传输机制。
应用程序的主loop
会更新comm
和Scheduler
实例,当PinDef
的period
到期时,Scheduler
会通知所有PinDef
客户端。PinDef
会在其period
到期时,通过调用指定的sendReading
回调函数将其读数发送到服务。
我不想在此处转储类实现,因为它们并没有什么特别有趣之处。
4.5 通信协议
当板卡管理服务启动时,它会将所有配置信息发送到板卡。
id,<boardId>$
:板卡通过此命令设置其板卡ID。响应“ok$
”
addpin,<pinId,pinNum,isAnalog,period>$
:板卡的引脚配置。
ping$
:服务检查板卡是否仍然连接
reading,<pinId,value>
:板卡通过此命令发送引脚读数。
一次对话示例
id,1$
(来自服务,将您的板卡ID设置为1)
ok$
(来自Arduino)
addpin,1,0,1,1$
(来自服务,每1分钟读取一次模拟引脚0。其ID为1)
ok$
(来自Arduino)
...
ping$
(来自服务)
...
reading,1,254$
(来自Arduino,254是从ID为1的引脚读取的)
...
实际上,此通信协议的实现不够可靠。消息未排序且未唯一编号。命令的响应消息未跟踪。因此,通信双方都需要进一步完善。
5. 值得关注的要点
本项目结合了许多技术和概念。总结如下:
- Arduino编程
- 串行通信编程
- WCF Windows服务
- Representational State Transfer (REST)
- 托管可扩展性框架 (MEF)
- Entity Framework (EF)
其中每个主题都包含许多概念,无法在一篇文章中涵盖。我在开发此项目时,看到它们都融合在一起,感到非常愉快。
此外,我计划使用Windows Presentation Foundation (WPF) [8] 来创建UI。配合所有编辑器和相关的报表工具,该项目可以作为任何物理环境监控的一体化解决方案(适当夸张一下没关系:))。
6. 下一步?
此项目非常开放,可以进行扩展。首先,我们需要一个数据编辑器来管理板卡和引脚。然后需要一个报表工具来查看读数。一个用于数据库创建和服务安装的设置应用程序会很棒。当这些用户应用程序完成后,我们可以开始尝试其他通信机制,如以太网。数据服务可以实现其他数据存储实现,例如MySQL数据提供者。我欢迎任何建议。
7. 参考文献
- [1] Arduino
- [2] Arduino Shields
- [3] Windows Communication Foundation
- [4] An Introduction To RESTful Services With WCF
- [5] WCF Web HTTP Programming Model
- [6] Managed Extensibility Framework (MEF)
- [7] Entity Framework (EF) Documentation
- [8] Windows Presentation Foundation
- [9] Fundamental Windows Communication Foundation Concepts
- [10] Windows Communication Foundation Bindings Overview
- [11] WCF Client Overview
- [12] Representational state transfer
- [13] Installutil.exe (Installer Tool)
[14] ADO.NET Entity Data Model Designer - [15] Dependency inversion principle
- [16] SOLID principles