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

Azure Veneziano – 第一部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2014年11月7日

CPOL

11分钟阅读

viewsIcon

9485

如何使用 Azure 创建您自己的遥测控件

Microsoft Azure logo

Microsoft Azure 标志

这是系列的第一部分,我将把一个遥测项目作为一个经典的“物联网”展示。该项目一开始非常基础,但在后续部分中会通过添加各种有用的组件来不断完善。
核心角色是 Microsoft Azure,但其他部分将涉及多种技术。

该项目的源代码托管在 azure-veneziano GitHub 仓库中。

灵感

该项目最初是一个沙盒,用于深入研究可能适用于我们的控制系统的云技术。我想遍历真实控制系统(如果喜欢,可以称之为 SCADA)的几乎每一个角落,以了解完全集中式解决方案的优点和局限性。

顺便说一句,我也受到了我的朋友 Laurent Ellerbach 的启发,他发表了一篇非常精彩的文章,介绍如何构建自己的花园洒水系统。总的来说,我喜欢不同组件混合在一起(即互联)的方式:似乎我们正面临一个里程碑,这些技术的灵活性超出了我们的想象。

在我写这篇文章的时候,Laurent 正在将他的文章从法语翻译成英语,所以我还在等待新的链接。同时,这里是最近在乌克兰基辅举行的一次类似的演讲

更新:Laurent 的文章现在可以在这里找到。

为什么命名为“Azure Veneziano”?

如果你们中有谁有机会参观过我的城市,可能也亲眼见过一些著名的穆拉诺岛(Murano)玻璃制造师的作品,而“Blu Veneziano”(威尼斯蓝)是一种特殊的蓝色调,常用于玻璃制品。
我只是想向威尼斯致敬,同时也提及所用框架的“颜色”,因此得名!

系统结构

系统结构为生产者-消费者模式,其中:

  • 数据生产者是一个(或多个)“移动设备”,它们采集并有时收集传感器数据
  • 数据代理、存储和业务层部署在 Azure 上,主要逻辑在此运行
  • 数据消费者既包括逻辑,也包括最终用户(在本例中是我自己),他们监控系统

在这篇入门文章中,我将重点介绍第一部分,使用一块 Netduino Plus 2 板作为数据生产者。

Netduino 作为数据生产者

在物联网视角下,Netduino扮演“移动设备”的角色。基本上,它充当了一个硬件-软件的薄接口,以便将转换后的数据发送到服务器(在本例中是 Azure)。可以想象一个温度传感器,连接到 ADC,逻辑读取数值并发送到 Azure。但是,我这里不会详细介绍一个“真实传感器”系统,而是做一个简单的模拟,任何人都可以轻松完成。
此外,由于我将项目定义为“遥测”,数据流仅从 Netduino“传出”。这意味着(目前)不支持向板发送“命令”。让我们坚持使用最简单的实现方式。

硬件

电路非常简单。

netduino_bb

两个微调电位器:每个微调电位器向 respective 模拟输入提供 0.0 到 3.3 V 的电压。也就是说,Netduino 的内部 ADC 会将电压转换为浮点(Double)值,范围从 0.0100.0(为了方便阅读,意味着它相当于百分比)。

还有两个拨动开关。每个开关连接到一个离散(布尔)输入,应配置为内部上拉。当开关打开时,上拉电阻将输入值拉高(true)。当开关接地时,由于其电阻低于上拉电阻,它将值拉低。以及两个开关。

如果注意到,每个开关串联了一个低值电阻:我使用的是 270 欧姆的电阻,但这并不重要。其目的只是为了保护 Netduino 输入免受错误影响。想象一下,一个引脚被错误地配置为输出:如果开关接地时输出设置为高电平怎么办?可能不会烧毁,但该端口承受的压力不是好事。

从程序员的角度来看,所有这些“虚拟”传感器都可以看作是两个 Double 值和两个布尔值。有趣的是,我可以用手指修改它们的值!

???????????

同样,这里的真实传感器是什么并不重要。我想为那些不太喜欢/理解电子学的人们改造硬件部分。有许多即用型模块/扩展板可以连接,可以避免(或最小化)处理硬件的机会。

一些虚拟端口和我的……懒惰。

相信我,我很懒。

尽管我非常享受玩弄这些软硬件的乐趣,但我真的不喜欢一直转动微调电位器或滑动开关,但我需要一些随时间变化的数据。所以,我创建了一种(软件)虚拟端口。

这个端口将在下面详细介绍,它的任务是模拟一个“真实”的硬件端口。从数据生产的角度来看,它与真实端口没有区别,但管理起来要容易得多,尤其是在测试/演示会话中。
这种“虚拟端口”的概念在高阶系统中也非常普遍。只需想想设备的诊断部分,它从非物理源(例如内存使用量、CPU 使用量等)收集数据。

软件

由于目标是将 Netduino 读取的数据发布到服务器,因此我们应该仔细选择最佳方法。

将 Netduino Plus 2 连接到外界的最简单方法是使用以太网线。至少对于原型来说,这是可以的,因为目标是连接到互联网。

关于协议,在与 Azure 交换数据的多种协议中,我认为最简单但又广为人知的方法是使用 HTTP。还要记住,当前的 Netduino/.NET Micro Framework 实现中没有任何“特殊”协议。

板上运行的软件非常简单。其结构如下:

  • 主应用程序,作为设备的主要逻辑
  • 一些硬件端口包装器作为数据捕获助手
  • 为 Azure 移动数据交换优化的 HTTP 客户端
  • 具有序列化/反序列化功能的 JSON DOM

数据传输是正常的 HTTP。在撰写本文时,.NET Micro-Framework 仍然不提供任何 HTTPS 支持,因此数据是未加密传输的。

主应用程序的第一部分是关于端口定义。它与经典的声明没有特别不同,但端口被“包装”在自定义代码中。

        /**
         * Hardware input ports definition
         **/

        private static InputPortWrapper _switch0 = new InputPortWrapper(
            "Switch0",
            Pins.GPIO_PIN_D0
            );

        private static InputPortWrapper _switch1 = new InputPortWrapper(
            "Switch1",
            Pins.GPIO_PIN_D1
            );

        private static AnalogInputWrapper _analog0 = new AnalogInputWrapper(
            "Analog0",
            AnalogChannels.ANALOG_PIN_A0,
            100.0,
            0.0
            );

        private static AnalogInputWrapper _analog1 = new AnalogInputWrapper(
            "Analog1",
            AnalogChannels.ANALOG_PIN_A1,
            100.0,
            0.0
            );

端口包装器

端口包装器的目标是双重的:

  • 为通用输入端口提供更好的抽象
  • 管理“已更改”标志,特别是对于非离散值(如模拟值)

让我们以 AnalogInputWrapper 类为例

    /// <summary>
    /// Wrapper around the standard <see cref="Microsoft.SPOT.Hardware.AnalogInput"/>
    /// </summary>
    public class AnalogInputWrapper
        : AnalogInput, IInputDouble
    {
        public AnalogInputWrapper(
            string name,
            Cpu.AnalogChannel channel,
            double scale,
            double offset,
            double normalizedTolerance = 0.05
            )
            : base(channel, scale, offset, 12)
        {
            this.Name = name;

            //precalculate the absolute variation window 
            //around the reference (old) sampled value
            this._absoluteToleranceDelta = scale * normalizedTolerance;
        }

        private double _oldValue = double.NegativeInfinity; //undefined
        private double _absoluteToleranceDelta;

        public string Name { get; private set; }
        public double Value { get; private set; }
        public bool HasChanged { get; private set; }

        public bool Sample()
        {
            this.Value = this.Read();

            //detect the variation
            bool hasChanged =
                this.Value < (this._oldValue - this._absoluteToleranceDelta) ||
                this.Value > (this._oldValue + this._absoluteToleranceDelta);

            if (hasChanged)
            {
                //update the reference (old) value
                this._oldValue = this.Value;
            }

            return (this.HasChanged = hasChanged);
        }

        // ...

    }

该类派生自原始的 AnalogInput 端口,但公开“Sample”方法来捕获 ADC 值(Read 方法)。其目的类似于经典的采样保持结构,但有一个比较算法可以检测新值的变化。

基本上,必须为端口定义一个“容差”参数(归一化)(默认为 5%)。当执行新的采样时,其值会与“旧值”进行比较,再加上旧值周围的容差窗口。当新值落在窗口之外时,官方端口的值将被标记为“已更改”,并且旧值将被新值替换。

这个技巧非常有用,因为它允许避免无谓的(和错误的)值更改。即使电源轨上的微小噪声也可能导致 ADC 标称采样值出现小的波动。但是,我们只需要“具体”的变化。

上述类也实现了 IInputDouble 接口。此接口也来自另一个更抽象的接口。

    /// <summary>
    /// Double-valued input port specialization
    /// </summary>
    public interface IInputDouble
        : IInput
    {
        /// <summary>
        /// The sampled input port value
        /// </summary>
        double Value { get; }
    }

    /// <summary>
    /// Generic input port abstraction
    /// </summary>
    public interface IInput
    {
        /// <summary>
        /// Friendly name of the port
        /// </summary>
        string Name { get; }

        /// <summary>
        /// Indicate whether the port value has changed
        /// </summary>
        bool HasChanged { get; }

        /// <summary>
        /// Perform the port sampling
        /// </summary>
        /// <returns></returns>
        bool Sample();

        /// <summary>
        /// Append to the container an object made up
        /// with the input port status
        /// </summary>
        /// <param name="container"></param>
        void Serialize(JArray container);
    }

这些接口为不同类型的端口提供了更好的抽象:AnalogInputInputPortRampGenerator

RampGenerator 作为虚拟端口

如前所述,存在一个“假包装器”,因为它不包装任何端口,但它起的作用就像一个标准端口。好处来自于接口抽象。

为了随时间“生成”数据进行演示,我想要一些自动化的但又“众所周知”的东西。我可能使用了随机数生成器,但是……如何检测随机数字流中的错误或错误序列?最好依赖一个形状完美的波形,它是周期性的,这样我就可以轻松地检查服务器上的样本顺序是否正确,以及是否有缺失/重复的数据。

作为周期信号,您可以选择任何您想要的。正弦波可能是最著名的周期波,但目标是测试系统,而不是有什么好看的东西。简单的“三角波”生成器,就是无限地线性上升然后下降的斜坡。

    /// <summary>
    /// Virtual input port simulating a triangle waveform
    /// </summary>
    public class RampGenerator
        : IInputInt32
    {
        public RampGenerator(
            string name,
            int period,
            int scale,
            int offset
            )
        {
            this.Name = name;
            this.Period = period;
            this.Scale = scale;
            this.Offset = offset;

            //the wave being subdivided in 40 slices
            this._stepPeriod = this.Period / 40;

            //vertical direction: 1=rise; -1=fall
            this._rawDirection = 1;
        }

        // ...

        public bool Sample()
        {
            bool hasChanged = false;

            if (++this._stepTimer <= 0)
            {
                //very first sampling
                this.Value = this.Offset;
                hasChanged = true;
            }
            else if (this._stepTimer >= this._stepPeriod)
            {
                if (this._rawValue >= 10)
                {
                    //hit the upper edge, then begin to fall
                    this._rawValue = 10;
                    this._rawDirection = -1;
                }
                else if (this._rawValue <= -10)
                {
                    //hit the lower edge, then begin to rise
                    this._rawValue = -10;
                    this._rawDirection = 1;
                }

                this._rawValue += this._rawDirection;
                this.Value = this.Offset + (int)(this.Scale * (this._rawValue / 10.0));
                hasChanged = true;
                this._stepTimer = 0;
            }
            
            return (this.HasChanged = hasChanged);
        }

        // ...

    }

三角波在示波器上的样子(100 Hz,仅供参考)。

UNIT0000

当然,我也可以像上图快照那样,使用普通的台式波形发生器作为物理信号源。那样会更真实,但预期的波形周期会太短(即太快),并且“变化”以及随之而来的消息上传会过于频繁。基于软件的信号生成器非常适合非常长的周期,比如很多分钟。

HTTP 客户端

如上所述,数据通过普通的(未加密)HTTP 发送到服务器。Netduino Plus 2 不提供任何 HTTP 客户端,但提供了一些可用于创建自己的原始工具。

简单来说,客户端非常简单。如果您知道基本的 HTTP 事务是如何工作的,那么您将很容易理解代码的作用。

    /// <summary>
    /// HTTP Azure-mobile service client 
    /// </summary>
    public class MobileServiceClient
    {
        public const string Read = "GET";
        public const string Create = "POST";
        public const string Update = "PATCH";

        // ...

        /// <summary>
        /// Create a new client for HTTP Azure-mobile servicing
        /// </summary>
        /// <param name="serviceName">The name of the target service</param>
        /// <param name="applicationId">The application ID</param>
        /// <param name="masterKey">The access secret-key</param>
        public MobileServiceClient(
            string serviceName,
            string applicationId,
            string masterKey
            )
        {
            this.ServiceName = serviceName;
            this.ApplicationId = applicationId;
            this.MasterKey = masterKey;

            this._baseUri = "http://" + this.ServiceName + ".azure-mobile.net/";
        }

        // ..

        private JToken OperateCore(
            Uri uri,
            string method,
            JToken data
            )
        {
            //create a HTTP request
            using (var request = (HttpWebRequest)WebRequest.Create(uri))
            {
                //set-up headers
                var headers = new WebHeaderCollection();
                headers.Add("X-ZUMO-APPLICATION", this.ApplicationId);
                headers.Add("X-ZUMO-MASTER", this.MasterKey);

                request.Method = method;
                request.Headers = headers;
                request.Accept = JsonMimeType;

                if (data != null)
                {
                    //serialize the data to upload
                    string serialization = JsonHelpers.Serialize(data);
                    byte[] byteData = Encoding.UTF8.GetBytes(serialization);
                    request.ContentLength = byteData.Length;
                    request.ContentType = JsonMimeType;
                    request.UserAgent = "Micro Framework";
                    //Debug.Print(serialization);

                    using (Stream postStream = request.GetRequestStream())
                    {
                        postStream.Write(
                            byteData,
                            0,
                            byteData.Length
                            );
                    }
                }

                //wait for the response
                using (var response = (HttpWebResponse)request.GetResponse())
                using (var stream = response.GetResponseStream())
                using (var reader = new StreamReader(stream))
                {
                    //deserialize the received data
                    return JsonHelpers.Parse(
                        reader.ReadToEnd()
                        );
                };
            }
        }
    }

上面的代码源自一个旧的项目,但实际上只有该版本中的几行代码。然而,我想为对此感兴趣的人提供来源。

Azure Mobile Services 所提供的,有两种类型的 API 可以调用:表(数据库)和自定义 API 操作。同样,我将在下一篇文章中详细介绍这些功能。
关键在于 OperateCore 方法,它是表和自定义 API 请求的私有入口点。所有 Azure 所需的是一些特殊的 HTTP 标头,其中应包含标识密钥以获得平台访问权限。
请求的内容只是一个 JSON 文档,也就是简单的纯文本。

主应用程序

程序启动时,它首先创建一个 Azure Mobile HTTP 客户端(Zumo)实例,然后将所有端口引用包装在一个数组中,以便于管理。

请注意,还有两个“特殊”端口称为“RampGenerator”。在此演示中,有两个波形生成器,周期分别为 1200 秒和 1800 秒。它们的范围也略有不同,只是为了减少数据验证时的混淆。

能够将所有端口放入一个数组中,然后将它们视为一个统一的实体,这就是接口抽象所提供的优势。

        public static void Main()
        {
            //istantiate a new Azure-mobile service client
            var ms = new MobileServiceClient(
                "(your service name)",
                applicationId: "(your application-id)",
                masterKey: "(your master key)"
                );

            //collect all the input ports as an array
            var inputPorts = new IInput[]
            {
                _switch0,
                _switch1,
                new RampGenerator("Ramp20min", 1200, 100, 0),
                new RampGenerator("Ramp30min", 1800, 150, 50),
                _analog0,
                _analog1,
            };

初始化后,程序将永远循环运行,大约每秒采样所有端口。一旦出现任何“具体”变化,就会用新值构建一个 JSON 消息,然后发送到服务器。

            //loops forever
            while (true)
            {
                bool hasChanged = false;

                //perform the logic sampling for every port of the array
                for (int i = 0; i < inputPorts.Length; i++)
                {
                    if (inputPorts[i].Sample())
                    {
                        hasChanged = true;
                    }
                }

                if (hasChanged)
                {
                    //something has changed, so wrap up the data transaction
                    var jobj = new JObject();
                    jobj["devId"] = "01234567";
                    jobj["ver"] = 987654321;

                    var jdata = new JArray();
                    jobj["data"] = jdata;

                    //append only the port data which have been changed
                    for (int i = 0; i < inputPorts.Length; i++)
                    {
                        IInput port;
                        if ((port = inputPorts[i]).HasChanged)
                        {
                            port.Serialize(jdata);
                        }
                    }

                    //execute the query against the server
                    ms.ApiOperation(
                        "myapi",
                        MobileServiceClient.Create,
                        jobj
                        );
                }

                //invert the led status
                _led.Write(
                    _led.Read() == false
                    );

                //take a rest...
                Thread.Sleep(1000);
            }

JSON 消息的构成也许是最简单的部分,因为这是我的Micro-JSON 库的 Linq 方式。

LED 闪烁只是一个视觉心跳监视器。

消息架构

在我看来,应该不止一个板。更好的是:一个更真实的系统应该连接多个设备,即使它们彼此不同。然后,每个设备应该提供自己的数据,所有进入服务器的数据将构成一大堆“变量”。

因此,区分数据源很重要,一种“设备标识符”,在系统中是唯一的,将包含在每条消息中。

此外,我认为设备暴露的变量集可以随时更改。例如,我可能会添加一些新传感器,重新排列输入端口,甚至调整某些数据类型。所有这些都意味着“配置已更改”,服务器应该收到通知。这就是为什么还有“版本标识符”。

然后是实际的传感器数据。它只是一个 JavaScript 对象数组,每个对象提供端口(传感器)名称及其值。

但是,该数组将仅包含标记为“已更改”的端口。这个技巧至少有两个优点:

  • 消息长度只包含有用数据
  • 这种方法相当“松耦合”:服务器自动同步

每个变量的序列化由 IInput 接口中声明的相应方法完成。下面是一个模拟端口的示例。

        public void Serialize(JArray container)
        {
            var jsens = new JObject();
            jsens["name"] = this.Name;
            jsens["value"] = this.Value;
            container.Add(jsens);
        }

这是初始消息,它始终包含所有值。

{
  "devId": "01234567",
  "ver": 987654321,
  "data": [
    {
      "name": "Switch0",
      "value": true
    },
    {
      "name": "Switch1",
      "value": true
    },
    {
      "name": "Ramp20min",
      "value": 0
    },
    {
      "name": "Ramp30min",
      "value": 50
    },
    {
      "name": "Analog0",
      "value": 0.073260073260073
    },
    {
      "name": "Analog1",
      "value": 45.079365079365
    }
  ]
}

之后,我们可以调整微调电位器和开关以产生“变化”。一旦检测到任何变化,就会构建并发出一条消息。

单一变化 多次变化
{
  "devId": "01234567",
  "ver": 987654321,
  "data": [
    {
      "name": "Analog1",
      "value": 52.503052503053
    }
  ]
}
{
  "devId": "01234567",
  "ver": 987654321,
  "data": [
    {
      "name": "Switch1",
      "value": false
    },
    {
      "name": "Analog1",
      "value": 75.946275946276
    }
  ]
}

结论

很容易认识到这个项目非常基础,而且有很多部分可以改进。例如,程序在抛出异常时没有任何恢复机制。但是,我想让应用程序保持非常入门级的水平。

是时候连接您自己的原型了,因为在下一篇文章中,我们将看到如何设置 Azure 平台进行数据处理。

© . All rights reserved.