另一个家庭监控器
本次竞赛提交是一个家庭监控解决方案,该方案读取家中各个传感器的温度、湿度和状态。数据存储在 Azure 中,可以通过移动设备访问。
引言
技术爱好者拿到嵌入式设备后,首先想做的就是什么?当然是读取温度。在这个例子中,我有一个地下室的酒柜,我想监控它,以便知道酒的温度是否保持恒定。好吧,那个小项目逐渐发展到云集成和多种传感器类型。
本项目使用 Netduino plus 2 读取几种传感器的数据。首先,一个带有几个热电偶传感器的串行 OneWire 温度总线。其次,一个模拟湿度传感器连接到其中一个模拟端口。最后,使用一些磁性开关,我可以通过 Netduino 上可用的数字端口监控我的两个车库门的状态。
当然,拥有这些数据是一回事,但能够访问它们,并可能远程控制 Netduino 是目标。Netduino 记录的所有数据都将发送到 Azure 并存储在 SQL 数据库中。此外,还将创建一个 Web REST 接口,以便能够获取 Netduino 记录的数据并在移动设备上显示。在这种情况下,我将有一个 Windows Phone 应用程序,它可以从 Azure SQL 实例读取传感器数据。
背景
本项目的前提是使用廉价的终端设备(Netduino)收集大量分散的信息,并将这些数据存储在云中。然后,使用常见的移动平台(智能手机)检索这些原始和已分析的数据,并无论用户身在何处都可以显示给用户。
我为该实现选择的嵌入式设备是 Netduino Plus 2,有关该设备的更多信息可以在此处找到:http://www.netduino.com/。我正在使用 OneWire 串行总线从多个热电偶读取温度数据。关于 OneWire 的信息很多,有些可以在此处找到:http://developer.mbed.org/users/alpov/code/OneWire/docs/tip/。我正在使用 DBS18B20 温度传感器放置在我正在收集温度数据的实际物理位置。
车库门的状态是通过磁性开关监控的。门关闭时,电路闭合;门打开时,电路断开,这由 Netduino 设备捕获。
湿度监控使用 HM1500LF 湿度传感器(http://www.meas-spec.com/product/t_product.aspx?id=2448#)连接到 Netduino 模拟端口。湿度是使用一个公式计算的,该公式使用传感器报告的电压偏移来得出湿度百分比。
Visual Studio 2013 是本项目所有代码开发的基础。我正在使用 C# 以及与 Azure 和 Netduino 通信所需的众多库。.Net Micro Framework 的 Netduino 运行版本,SDK 和文档可以在此处找到:https://msdn.microsoft.com/en-us/library/ee436350.aspx
要设置 Netduino 开发的 VS 2013 环境,请按照此处的安装说明进行操作:http://www.netduino.com/downloads/
我将实现 Azure 中的存储和通信,所有与 Azure 相关的内容都可以在此处找到:http://azure.microsoft.com/en-us/。特别是,我将使用云服务、服务总线、SQL 服务和网站。
架构
Home Monitor 的架构是一个多层服务,其中一层是带有传感器的实际嵌入式设备,提供存储和智能的云服务(Azure),以及用于用户界面(UI)的移动平台。
这基于 IofT 模式,其中有一个入站服务队列,它与大量嵌入式设备存在多对一关系。还有一个一对一的关系用于设备命令队列,其中提交了将在远程嵌入式设备上执行的命令。
Netduino 上运行的 .Net Compact Framework 具有大量功能,但不提供完整的 .Net 或 Windows 环境。因此,无法运行可以直接与 Azure 中可用的服务通信的“标准”Windows 客户端。在这种情况下,我正在使用一种网关式方法来提供与远程嵌入式设备的通信。Netduino 使用基于套接字的 TCP/IP 通信连接并向 hmEnqueue Azure 云服务传输数据,该服务处理这些入站消息,并在 InBoundTemps Service Bus 队列上创建条目以供进一步处理。
从 Netduino 发送到 hmEnqueue 的数据采用序列化的 XML 格式,并直接放入 InBoundTemps 队列。然后,InBoundTemps 队列上的 XML 数据由 hmDequeue 工作角色反序列化并存储在 IofT SQL 数据库中。
hmNotify 工作角色负责监控 IofT 数据库,并在一段时间内未收到数据时创建异常消息。在此实现中,一个小时内未记录的温度数据将引发异常。我正在使用一个提供电子邮件接口给 hmNotify 服务的 App Service (SendGridEmail)。根据 IofT 数据库中的配置数据,如果配置传感器的温度数据在一个小时内未被记录,将向相关方生成异常电子邮件。对于此特定实例,我正在使用我的智能手机的电子邮件地址从 Azure 获取这些异常的 SMS 消息。
我还创建了 azHomeMonitor 网站来托管移动设备可以使用的 REST 服务。这些服务允许在智能手机上显示最近的温度、湿度读数、车库门的过去 24 小时活动和 24 小时平均值。
hmEnqueue 服务还为每个终端设备创建一个特定的队列。这通常是为将来使用而设计的,但设想允许将远程命令传输到 Netduino 设备。我已经通过测试代码实现了这一点,但目前还没有公开一种以编程方式控制嵌入式设备的方法。
使用代码
Netduino
嵌入式代码在启动时执行以下任务
-
初始化网络
-
使用 NTP 服务器设置当前时间(此处需要 DST 支持)
-
创建我们要测量的传感器的对象
-
初始化 OneWire 接口并读取总线上的传感器数量
-
初始化数字端口以检查状态变化
-
监控的引脚是 8、9、10、11、12、13
-
为每个监控的端口创建中断处理程序
-
-
初始化第一个模拟端口和湿度传感器
-
-
创建用于检查的计时器
-
温度(2 分钟)
-
湿度(5 分钟)
-
数字端口监控(1 秒)
-
闪烁 LED(.75 秒短,2.5 秒长)
-
-
创建板载按钮的中断处理程序
-
从板载 LED 熄灭状态开始
-
永远循环检查网络通信
网络通信
这个永远循环执行以下任务
-
如果存在套接字连接,则进入 do-while 循环,通过已建立的 TCP/IP 套接字查找入站命令
-
接收数据(发布在特定于此 Netduino 的 Azure 队列上的远程命令)时,执行操作。当前支持的操作是
-
更改 LED 闪烁时序
-
导致传输温度数据
-
导致传输数字端口数据
-
转储所有当前数据
-
打开/关闭调试
-
-
如果没有可用数据(超时)或接收命令时发生异常
-
向 hmEnqueue 服务发送 PING
这将导致已关闭的套接字连接重新打开,并告知队列服务该设备仍然正常运行
-
-
LED 计时器
此例程非常简单,它只是打开/关闭 LED。这使我能够即时获得嵌入式设备状态的反馈。间隔可以远程更改,但主要目的是手动查看设备状态。我认为 Netduino 的 TCP/IP 堆栈存在一个错误,可能导致整个设备挂起。这就是闪烁 LED 的原因。当我看到 LED 不闪烁,或者系统发送消息告诉我一个小时内未记录任何数据时,我可以重置设备并使其重新工作。
OneWire 计时器(默认 2 分钟)
此计时器会在每次触发时读取 OneWire 总线上所有热电偶设备的当前温度。温度收集在一个本地温度对象列表中,并每 5 次计时器中断(默认 10 分钟)传输到 hmEnqueue 服务。当此计时器触发第 5 次时,将生成网络消息并发送到 hmEnqueue 服务,其中包含记录的温度。然后重置这些温度的本地列表,过程重新开始。
出站数据被序列化为 XML 格式以传输到 hmEnqueue 服务。虽然比 JSON 稍微冗长一些,但在此案例中我想使用 XML 来平衡该解决方案,展示超过一种序列化/反序列化对象模型的方法(JSON)。
获取热电偶温度的实际代码如下所示。
public double getTemperature(byte[] device,DeviceType deviceType) { resetBus(); selectDevice(device); convertTemp(); resetBus(); selectDevice(device); double temp = readTemp(deviceType); return temp; } public double readTemp(DeviceType deviceType) { int theTemp = 0; double dtemp = 0; byte[] results = new byte[45]; byte[] readTemp = new byte[] { W, 0, A, B, E, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, CR }; writeData(readTemp, 24); if (readData(results, CR) > 0) { int half = 0; int sign = 0; switch (deviceType) { case DeviceType.DS18B20: theTemp = convertHexCharByte(results[5]) & 0x07; // LSb of MSB and mask out the sign bits theTemp = (theTemp << 4 | convertHexCharByte(results[2])); // Shift and add in MSb of LSB half = convertHexCharByte(results[3]); // Take LSb of LSB for decimal temp bits sign = convertHexCharByte(results[4]) & 0x01; // Check if negative temperature dtemp = (double)theTemp; if (half > 0) dtemp += ((double)half)/10.0; if (sign > 0) dtemp *= -1; break; case DeviceType.DS1822: theTemp = convertHexCharByte(results[2]); theTemp = (theTemp << 4 | convertHexCharByte(results[3])); // Shift in the two actual temperature bytes theTemp = (theTemp >> 1); // Shift out the decimal half = convertHexCharByte(results[3]) & 0x01; // Check if decimal .5 is indicated sign = convertHexCharByte(results[4]) & 0x01; // Check if negative temperature dtemp = (double)theTemp; if (half > 0) dtemp += .5; if (sign > 0) dtemp *= -1; break; } } return dtemp; }
湿度传感器计时器(默认 5 分钟)
此计时器处理程序读取模拟端口并创建一个当前的湿度读数。通过读取模拟输入的电压并计算实际湿度(百分比),来计算当前湿度。它还将此读数的日期/时间与湿度一起存储在湿度读数列表中。当收集到 3 个读数时(默认 15 分钟),这些数据将被序列化为 XML 并传输到 hmEnqueue 服务。
public double readHumidity() { double voltage = analogInput.Read() * _referenceVoltage; humidity = 0.03892 * (voltage*1000) - 42.017; // Voltage in mv lastReading = DateTime.Now; return humidity; }
数字端口计时器(默认 1 秒)
虽然实际事件(数字状态变化)由中断处理程序处理,但此计时器例程会查看数字状态更改列表,以查看是否创建了任何条目。如果列表中存在条目,则将其序列化为 XML 并传输到 hmEnqueue。然后清除列表。
板载按钮中断
此中断处理程序将创建一个出站 XML 消息,并将该消息传输到 hmEnqueue 服务。它将收集并传输温度、数字端口数据和湿度数据,并重置这些出站列表。
入站消息
发送到设备的邮件由 processMessage 循环处理。目前,这非常有限,但允许更改 LED 的闪烁速率并将调试模式打开/关闭。您还可以发送命令以根据需要转储队列中的所有数据或部分数据。
azEnqueue
此工作角色初始化网络堆栈,创建一个套接字进行监听,然后监听入站连接。当检测到入站连接时,会创建一个线程从该套接字接收数据,然后监听器继续监听。
public netListen(IPEndPoint npEndPoint, ref QueueClient inboundQueue, addQueueDelegate addQueue ) { _inboundDataQueue = inboundQueue; listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); //Initialize Socket class npEndPoint.Address = IPAddress.Any; listenSocket.Bind(npEndPoint); //Request and bind to an IP from DHCP server listenSocket.Listen(10); //Start listen for net requests log("Listening on configured endpoint..."); }
读取数据的线程负责获取入站 XML 流并将其放入 InBound Temps 队列以存储在 SQL 数据库中。
public void readSocket() { IPEndPoint clientIP = clientSocket.RemoteEndPoint as IPEndPoint; log("New connection from: " + clientIP.Address.ToString()); byte[] buffer = new byte[_bufferSize]; string message = ""; int readByteCount = 0; clientSocket.SendTimeout = 10000; clientSocket.ReceiveTimeout = 90000; // 90 second receive timeout, should get pings every minute while (isRunning && (clientSocket != null)) { message = ""; try { do { readByteCount = clientSocket.Receive(buffer, _bufferSize, SocketFlags.None); if (readByteCount > 0) { message += Encoding.UTF8.GetString(buffer, 0, readByteCount); Thread.Sleep(80); // Give the slower device a chance to catch up } } while (clientSocket.Available > 0); if (readByteCount != 0) processMessage(message); else { log("Closed connection from: " + clientIP.Address.ToString()); RequestStop(); } } catch (Exception ex) { log("RecvEx: " + ex.Message); RequestStop(); } } } public void processMessage(string message) { int index = message.IndexOf("<##>"); // Look for first occurance of header (for multiple XML messages) string subMessage = ""; while (index >= 0) { index += 5; int nextXML = message.IndexOf("<##>", index); // Look for next occurance of header, if any if (nextXML > 0) subMessage = message.Substring(index, nextXML - index); else subMessage = message.Substring(index); processXML(subMessage); index = nextXML; } } public void processXML(string message) { XmlDocument xDoc = new XmlDocument(); try { if (message.IndexOf("</Home") >= 0) // Real XML message, not just a single line ping { xDoc.LoadXml(message); // Load to validate XML log("MsgRecvd: Putting on queue now."); BrokeredMessage brokered = new BrokeredMessage(message); inboundDataQueue.Send(brokered); // put on inbound data queue } else { if( macAddress == null ) { int client = message.IndexOf("Client"); if( client > 0 ) { int fquote = message.IndexOf('"',client); int squote = message.IndexOf('"', fquote+1); macAddress = message.Substring(fquote + 1, (squote - fquote-1)); sendCommands = new Thread(readCommandQueue); sendCommands.Start(); } } } } catch (SystemException se) { log("Excp (processsXML): " + se.Message); log(message); } } public bool sendMessage(string message) { try { int sent = clientSocket.Send(Encoding.UTF8.GetBytes(message), message.Length, SocketFlags.None); if (sent == message.Length) return true; } catch (SocketException se) { log("SendEx: " + se.ErrorCode.ToString()); RequestStop(); } return false; }
processMessage 函数能够“看到”多个 XML 消息。对于这项工作,我在 XML 消息之间放置了一个简单的标记(<##>),允许 processMessage 函数分隔不同的 XML 数据流。这些分隔的流用于创建 BrokeredMessage 对象,然后将这些对象放入 InBound Temps 队列。
每 60 秒收到的 Ping 消息现在将被此工作角色丢弃。但是,它们可以被处理并中继到某种系统监视器,以近乎实时地监视嵌入式设备。
IofT SQL 数据库
IofT 数据库中有 7 个表。它们是
-
ndTemps – 用于存储温度数据
-
记录 ID
-
Device
-
温度计(唯一的 OneWire 设备地址)
-
采样时间
-
摄氏度
-
-
ndHumidity – 用于存储湿度数据
-
记录 ID
-
设备(MAC 地址)
-
湿度
-
采样时间
-
模拟端口
-
-
ndStates – 用于存储数字状态数据(即车库门状态)
-
记录 ID
-
设备(MAC 地址)
-
端口
-
操作时间
-
isOpen(电路开启指示器)
-
-
ndDevices – 配置表,包含系统中所有设备
-
描述(主要是位置)
-
设备(MAC 地址)
-
类型
-
通知邮件地址
-
-
ndHumidSensor – 配置表,包含系统中所有湿度传感器
-
记录 ID
-
设备(MAC 地址)
-
模拟端口 – 传感器连接的模拟端口
-
描述(位置)
-
类型
-
-
ndPorts – 配置表,包含数字状态的端口映射
-
记录 ID
-
设备(MAC 地址)
-
端口
-
描述
-
-
ndTherms – 配置表,包含系统中所有温度传感器
-
描述
-
温度计 – 设备唯一地址(仅在 OneWire 总线上唯一,不是设备)
-
设备(MAC 地址)
-
类型
-
azDequeue
此工作角色初始化 InBound Temps 队列,然后在该队列上启动消息泵。在初始化期间,将创建必要的 SQL 表(如上所述),如果它们尚不存在。
从 Netduino 入站的消息被处理并放入 XMLDocument 对象中。然后检查文档中的节点,从 XMLDocument 中检索数据并将其存储在 SQL 数据库中。
提取数字端口数据,并使用以下 SQL 语句将新行插入 ndStates 数据库。
"INSERT INTO ndStates (Device, Port, Time, isOpen) values ('" + client + "', '" + port + "', '" + time + "', '" + open + "')"
提取湿度数据,并使用以下 SQL 语句将新数据插入表中。
INSERT INTO ndHumidity (Device, Humidity, Time, AnalogPort) values ('" + client + "', '" + humidity + "', '" + time + "', '" + analog + "')"
提取温度数据并使用以下 SQL 语句。
"INSERT INTO ndTemps (Device, Thermometer, Time, Celsius) values ('" + client + "', '" + device + "', '" + time + "', " + temp + ")"
azNotify
此工作角色负责监控 SQL 数据库中的数据并报告状态。该角色查看配置数据库(ndDevices)并创建系统中设备对象的列表。然后它运行一个连续循环,检查这些设备在过去一小时内的数据更新。如果查询没有返回数据,则调用 sendMail 过程,该过程将向该设备配置的电子邮件地址发送电子邮件。
bool sendMail(string contactEmail, string message) { bool sent = false; try { MailMessage newMail = new MailMessage("youremail@hotmail.com", contactEmail); newMail.Subject = "HM: Activity"; newMail.Body = message; SmtpClient msgClient = new SmtpClient("smtp.sendgrid.net"); msgClient.Credentials = new NetworkCredential("azure_<yourcredshere>@azure.com", "<key>"); msgClient.Send(newMail); sent = true; } catch (Exception e) { log("hmNotify:SendMail() Exception: " + e.Message + ", " + e.InnerException.Message,true); } return sent; }
此函数使用第三方 Azure 应用程序 SendMail 来提供 SMTP 服务。
azHomeMonitor
这是一个简单的网站,实现了一个 Controller 对象,可以处理以下请求
-
GET: /hm/devices/id
-
GET: /hm/therm/id
-
GET: /hm/temp/id
-
GET: /hm/ports/id
-
GET: /hm/state/id
-
GET: /hm/humidity/id
-
GET: /hm/averages/id
这是湿度 Action 的代码
public ActionResult Humidity(string Id) { List<hmHumidity> humidities = new List<hmHumidity>(); sqlData.sqlCommand.CommandText = "select top 1 ndHumidity.Device, ndDevices.Description, ndDevices.Type, ndHumidSensor.Description, ndHumidSensor.Type, ndHumidity.Time, ndHumidity.Humidity from ndHumidity inner join ndDevices on ndHumidity.Device = ndDevices.Device inner join ndHumidSensor on ndHumidSensor.AnalogPort = ndHumidity.AnalogPort where ndHumidity.Device = '" + Id + "' order by ndHumidity.Time desc"; SqlDataReader reader = sqlData.sqlCommand.ExecuteReader(); while (reader.Read()) { IDataRecord record = (IDataRecord)reader; hmHumidity humidity = new hmHumidity(); humidity.device = (string) record[0]; humidity.description = (string) record[1]; humidity.type = (string) record[2]; humidity.sensorDescription = (string) record[3]; humidity.sensorType = (string) record[4]; humidity.time = (DateTime)record[5]; humidity.humidity = (double) record[6]; humidities.Add(humidity); } return Json(humidities, JsonRequestBehavior.AllowGet); }
您可以在此处实时查看 API:http://azhomemonitor.azurewebsites.net/Help
Windows Phone 应用程序
我确实创建了一个 Windows 8.0 手机应用程序,该应用程序可以消耗上面列出的 Web 服务中的 JSON,但正在用一个 Windows 8 通用应用程序替换它,该应用程序将能够与 Netduino 设备进行通信。所以,请继续关注更多相关信息。
关注点
当我开始这个项目时,我不确定 Service Bus 队列的订阅或主题是否已经存在。根据我对该机制的理解,我将在未来研究它。创建单个出站队列用于所有设备,但通过主题专门针对单个设备的能力将非常有趣。
我还设想创建一个可移植类库,并仅使用 XML/JSON 序列化例程来序列化类,这样我就不必直接操作任何 XML 或 JSON 数据。我已在另一个基于 Azure 的系统中实现了这一点,因此知道它有效并且代码量更少。
历史
我于 2014 年初开始此项目,但将其搁置了一段时间。当我看到这个竞赛时,我决定复活它并完成它。当然,这导致了一些期望的改进(如上所述)以及对手机应用程序的重写。我想没有项目是完全“完成”的。