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

另一个家庭监控器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.69/5 (15投票s)

2015年3月5日

CPOL

13分钟阅读

viewsIcon

22070

本次竞赛提交是一个家庭监控解决方案,该方案读取家中各个传感器的温度、湿度和状态。数据存储在 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 熄灭状态开始

  • 永远循环检查网络通信

网络通信

这个永远循环执行以下任务

  1. 如果存在套接字连接,则进入 do-while 循环,通过已建立的 TCP/IP 套接字查找入站命令

    1. 接收数据(发布在特定于此 Netduino 的 Azure 队列上的远程命令)时,执行操作。当前支持的操作是

      1. 更改 LED 闪烁时序

      2. 导致传输温度数据

      3. 导致传输数字端口数据

      4. 转储所有当前数据

      5. 打开/关闭调试

    2. 如果没有可用数据(超时)或接收命令时发生异常

      1. 向 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 年初开始此项目,但将其搁置了一段时间。当我看到这个竞赛时,我决定复活它并完成它。当然,这导致了一些期望的改进(如上所述)以及对手机应用程序的重写。我想没有项目是完全“完成”的。

又一个家庭监控器 - CodeProject - 代码之家
© . All rights reserved.