使用Azure IoT Hub构建智能灯


Click here to read English version

在这篇文章中我们将使用Azure IoT Hub构建一个声控智能灯。所需的所有硬件如下图所示:一个树莓派、三个LED、3个220Ω电阻、一个面包板、一些杜邦线以及Amazon Echo Dot。

All hardwares we need

如果你没有哪个硬件,甚至什么硬件都没有也不必担心,我们仍然可以使用模拟设备来让它工作,我会在文章的结尾说明如何使用模拟设备进行操作。

在我们开始这个有趣的工作前,需要先清楚信息从你发出的声音到开关灯状态的整个流程。首先Amazon Echo Dot记录下你的声音并发送给Amazon的云服务器,之后Amazon云服务器将其解析为指令。接着Amazon云服务器将指令传到Azure IoT Hub的服务端,Azure IoT Hub服务端又把它传给Azure IoT Hub的客户端。在这篇文章中我们使用树莓派作为客户端。最终,树莓派通过GPIO控制LED的开和关。

第一步你需要设置Azure IoT Hub。Azure IoT Hub提供免费的套餐,所以你现在并不需要为其付费。你可以按照https://github.com/Azure/azure-iot-sdks/blob/master/doc/setup_iothub.md的步骤进行设置。注意,如果你想使用Azure IoT Hub的免费套餐,需要将Pricing and scale tier从S1 - Standard改为F1 - Free。免费套餐有每天8000条消息的限制,但对我们的实验来说已经足够了对吧!

在设置好Azure IoT Hub之后,我们需要在其中先创建一个设备。你可以按照https://github.com/Azure/azure-iot-sdks/blob/e1c8c6df558823f21bd94875d940cdb864b490b8/doc/manage_iot_hub.md中的步骤来创建你的设备。记住你所创建设备的名字,我们会在后面的步骤中用到它。

现在来把Azure IoT Hub铺设到服务端。选择一个支持Node的服务器,比如Azure的web app。对于Azure IoT Hub来说Azure web app不是必要的,如果你已经有了一个支持Node的服务器尽管使用它。

我使用express generator来快速创建一个node网站,你也可以使用你喜欢的工具。可以通过简单运行下面的命令安装express generator

npm install -g generator-express

如果这条指令无法正常运行,尝试以管理员权限运行

sudo npm install -g generator-express

进入Azure IoT Hub服务端本地开放环境的根目录,运行

yo express

选择如下选项:

? Would you like to create a new directory for your project? Yes
? Enter directory name {appname}
? Select a version to install: MVC
? Select a view engine to use: Jade
? Select a css preprocessor to use (Sass Requires Ruby): None
? Select a database to use: None
? Select a build tool to use: Grunt

完成上面的步骤后,你就可以在当前路径下看到名为{appname}的文件夹,进入并在其中的package.json文件的dependencies中添加如下三行:

"azure-iothub": "^1.0.18",
"azure-event-hubs": "^0.0.4",
"uuid": "^2.0.3"

然后npm install来使更改生效。运行npm start,并在浏览器中打开http://127.0.0.1:3000,你会看到一个写着Generator-Express MVC的页面。

Generator Express default homepage

你已经完成了本地开发环境的搭建,干得不错!现在让我们来编写服务端的核心代码。

进入app文件夹下的controllers文件夹,你会看到一个名为home.js的文件,它是你的网站的路由。让我们用如下代码添加/api/smarthome路径:

router.get('/api/smarthome', function(req, res, next) {
    res.header('Content-Type', 'application/json');
    res.header('Access-Control-Allow-Origin', '*');
    res.render('json', {
        json: {message: 'foo'},
        layout: false
    });
});

返回app文件夹,进入views文件夹,创建名为json.jade的文件,并写入如下代码:

!=JSON.stringify(json)

现在重启你的网站,并访问http://127.0.0.1:3000/api/smarthome。你可以通过Ctrl+C来停止服务器,再运行npm start重新启动它。

Smart Home API Router

你能看到页面中有一个JSON数据对吧?恭喜你,你在你的网站中创建了一个API的路由!

下面我们需要把它变成一个真正的API。进入app文件夹中的models文件夹,创建iot-hub.js文件。在这个文件中我们使用Azure IoT Hub模块。

'use strict';

var IoTHubClient = require('azure-iothub').Client;
var Message = require('azure-iot-common').Message;

var targetDevice = '[Target Device Id]';
var connectionString = '[Connection String]';

var iotHubClient = IoTHubClient.fromConnectionString(connectionString);

function sendC2DMessage(msg, targetDevice) {
    targetDevice = targetDevice || 'Chrome';
    iotHubClient.open(function (err) {
        if (err) {
            console.error('Could not connect: ' + err.message);
        } else {
            console.log('Client connected');

            // Create a message and send it to the IoT Hub
            var data = JSON.stringify({ message : msg });
            var message = new Message(data);
            console.log('Sending message: ' + message.getData());
            iotHubClient.send(targetDevice, message);
        }
    });
}

module.exports = sendC2DMessage;

[Target Device Id]替换成你在hub中创建的设备ID。我们在文章的开始创建了这个设备还记得吗?把[Connection String]替换成你自己的Connection String。Connection String是一串类似于如下格式的字符

HostName=xxx.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey=...

现在我们已经把IoT Hub模块引入到了我们的网站中,并且可以在我们的应用中使用它。创建一个名为smarthome-turnonoff.js新模型,并在其中编写处理开关灯指令的函数:

function TurnOnOff(applianceId, requestType) {
    var sendC2DMessage = require('./iot-hub.js')
    new sendC2DMessage({
        applianceId: applianceId,
        request: requestType
    }, '[Target Device Id]');
    var result = {
        applianceId: applianceId,
        request: requestType
    };
    
    return result;
}

module.exports = TurnOnOff;

同样记得将[Target Device Id]替换成你自己的设备ID。

在controllers文件夹下的home.js文件中,对/api/smarthome路由做以下更改:

router.get('/api/smarthome2', function(req, res, next) {
    res.header('Content-Type', 'application/json');
    res.header('Access-Control-Allow-Origin', '*');
    switch(req.query.request) {
        case 'TurnOnRequest' :
            res.render('json', {
                json: new TurnOnOff(req.query.applianceId, 'TurnOn'),
                layout: false
            });
            break;
        case 'TurnOffRequest' :
            res.render('json', {
                json: new TurnOnOff(req.query.applianceId, 'TurnOff'),
                layout: false
            });
            break;
    }
});

同时在文件顶部添加var TurnOnOff = require('../models/smarthome-turnonoff.js');

重启你的网站并访问http://127.0.0.1:3000/api/smarthome?request=TurnOnRequest&applianceId=34f8d140-0704-4d2a-b449-bf2c458afa0a,你会看到一行JSON数据显示你所发送的id,打开Azure portal中的IoT Hub控制台,在overview标签中你会发现你已经发出了一条消息。

IoT Hub Dashboard

现在让我们来设置客户端,树莓派。

首先我们需要设置Azure IoT Hub开发环境。从https://github.com/Azure/azure-iot-sdks把Azure IoT SDK下载到本地。因为这个Repo保护submodule,使用git时要记得使用recursive命令:

git clone --recursive https://github.com/Azure/azure-iot-sdks.git

然后按照https://github.com/Azure/azure-iot-sdks/blob/e1c8c6df558823f21bd94875d940cdb864b490b8/doc/get_started/python-devbox-setup.md所示的步骤进行设置。

完成开发环境的设置之后,在你的树莓派中创建名为iothub.py的文件。记得将iothub_client.so拷贝到你所创建文件的同一目录中。在这个文件中写入如下代码:

import iothub_client
from iothub_client import *
from time import sleep
import json

timeout = 241000
minimum_polling_time = 9
receive_context = 0
connection_string = '[Connection String]'

def receive_message(message, counter):
  buffer = message.get_bytearray()
  size = len(buffer)
  message = json.loads(buffer[:size].decode('utf-8')).get('message')
  print("Received Data: <%s>" % message)
  return IoTHubMessageDispositionResult.ACCEPTED

def iothub_init():
  iotHubClient = IoTHubClient(connection_string, IoTHubTransportProvider.HTTP)
  iotHubClient.set_option("timeout", timeout)
  iotHubClient.set_option("MinimumPollingTime", minimum_polling_time)
  iotHubClient.set_message_callback(receive_message, receive_context)
  while True:
    sleep(10)

if __name__ == '__main__':
  iotHubClient = iothub_init()

同一将[Connection String]替换成你自己的Connection String。此处的Connection String是设备的Connection String,所以它看起来是这样的

HostName=xxx.azure-devices.net;DeviceId=xxx;SharedAccessKey=...'

看到了吗,你可以发现其中包含一个DeviceId参数。你可以在Azure portal的Devices标签里找到它。

Device Connection String

让我们来运行这个脚本,然后重新访问我们的网站。看,树莓派已经接收到了我们的请求!

Terminal

下面我们需要让树莓派控制LED。下面就是我在面包板上连接LED与电阻、杜邦线的方法。

LED board

蓝色的线是GND,也就是我们熟知的地线,其他三条线,白、灰和紫是灯的控制线。我们需要把它们连接到树莓派的GPIO接口上。未来让大家更清楚,我画了一幅图来展示如何进行连线。

RPi GPIO

现在我们需要一个叫RPi.GPIO的python模块,你可以通过https://pypi.python.org/pypi/RPi.GPIO进行下载。下载完RPi.GPIO之后,解压缩并运行setup.py。

让我们来创建一个脚本来通过GPIO控制LED。创建led.py文件并写入如下代码:

import RPi.GPIO as GPIO
from time import sleep

GPIO.setmode(GPIO.BCM)
GPIO.cleanup(17)
GPIO.cleanup(27)
GPIO.cleanup(22)
GPIO.setup(17, GPIO.OUT)
GPIO.setup(27, GPIO.OUT)
GPIO.setup(22, GPIO.OUT)
GPIO.output(17, False)
GPIO.output(27, False)
GPIO.output(22, False)
while True:
  GPIO.output(17, True)
  sleep(1)
  GPIO.output(17, False)
  GPIO.output(27, True)
  sleep(1)
  GPIO.output(27, False)
  GPIO.output(22, True)
  sleep(1)
  GPIO.output(22, False)

现在你能看到这些LED依次亮灭。

不错!你能够使用代码控制硬件了,很酷吧!

接着让我们把iothub.py和led.py这两个脚本结合在一起。为新的脚本起一个酷一点的名字,比如叫smartlight.py。写入如下代码:

import iothub_client
from iothub_client import *
from time import sleep
import json
import RPi.GPIO as GPIO

green_led_id = 'db87ffe4-5d5d-4af7-bb70-da8a43beac90'
red_led_id = '1266ab90-b23d-4e0f-83d0-ec162284952f'
yellow_led_id = '7b38a9f2-c9f4-42cf-bb63-59147eb685b4'

led_gpio = {green_led_id: 22, red_led_id: 27, yellow_led_id: 17}

GPIO.setmode(GPIO.BCM)
GPIO.setup(led_gpio[green_led_id], GPIO.OUT)
GPIO.setup(led_gpio[red_led_id], GPIO.OUT)
GPIO.setup(led_gpio[yellow_led_id], GPIO.OUT)
GPIO.output(led_gpio[green_led_id], False)
GPIO.output(led_gpio[red_led_id], False)
GPIO.output(led_gpio[yellow_led_id], False)

timeout = 241000
minimum_polling_time = 9
receive_context = 0
connection_string = '[Connection String]'

def receive_message(message, counter):
  buffer = message.get_bytearray()
  size = len(buffer)
  message = json.loads(buffer[:size].decode('utf-8')).get('message')
  print("ID: %s\nRequest: %s" % (message['applianceId'], message['request']))
  if message['request'] == 'TurnOn':
    GPIO.output(led_gpio[message['applianceId']], True)
  elif message['request'] == 'TurnOff':
    GPIO.output(led_gpio[message['applianceId']], False)
  return IoTHubMessageDispositionResult.ACCEPTED

def iothub_init():
  iotHubClient = IoTHubClient(connection_string, IoTHubTransportProvider.AMQP)
#  iotHubClient.set_option("timeout", timeout)
#  iotHubClient.set_option("MinimumPollingTime", minimum_polling_time)
  iotHubClient.set_message_callback(receive_message, receive_context)
  while True:
    sleep(10)

if __name__ == '__main__':
  iotHubClient = iothub_init()

'[Connection String]'替换成你自己的Connection String。我是不是有点太啰嗦了……

好了,见证奇迹的时刻到了!在树莓派中运行脚本,然后重启你的网站,访问http://127.0.0.1:3000/api/smarthome2?request=TurnOnRequest&applianceId=1266ab90-b23d-4e0f-83d0-ec162284952f。

看到了吗!红色的LED亮了,是吧!现在你可以通过HTTP请求来控制灯泡了!

当然,这还不算是完整的智能灯,我们应该弄一个更友好的控制方式。下面我们来引入Amazon Echo Dot!!

你可以通过https://developer.amazon.com/public/community/post/Tx4WG410EHXIYQ/Five-Steps-Before-Developing-a-Smart-Home-Skill了解如何创建一个Alexa Smart Home Skill。

要把LED加入到你的Alexa控制台,我们需要使用Alexa Smart Home Skill的discovery请求,控制LED我们需要control请求。

下面是我编写的Smart Home Skill Lambda函数:

var https = require('https');
var REMOTE_CLOUD_BASE_PATH = '/api/smarthome';
var REMOTE_CLOUD_HOSTNAME = '[Cloud Hostname]';

exports.handler = function(event, context) {

    log('Input', event);

    try{
        switch (event.header.namespace) {
            case 'Alexa.ConnectedHome.Discovery':
                handleDiscovery(event, context);
                break;
            case 'Alexa.ConnectedHome.Control':
                handleControl(event, context);
                break;
            default:
                log('Err', 'No supported namespace: ' + event.header.namespace);
                context.fail('Something went wrong');
                break;
        }
    }
    catch(e) {
        log('error', e);
    }
};

function handleDiscovery(event, context) {
    log('Discovery', event);
    var basePath = '';
    basePath = REMOTE_CLOUD_BASE_PATH + '?request=Discovery';
    var options = {
        hostname: REMOTE_CLOUD_HOSTNAME,
        port: 443,
        path: basePath,
        headers: {
            accept: 'application/json'
        }
    };

    log('Discovery', options);
    
    var serverError = function (e) {
        log('Error', e.message);
        context.fail(generateControlError(event.header.name,
            'DEPENDENT_SERVICE_UNAVAILABLE',
            'Unable to connect to server'));
    };

    var callback = function(response) {
        log('Discovery Get', response);
        var str = '';

        response.on('data', function(chunk) {
            str += chunk.toString('utf-8');
            log('Discovery Data', str);
        });

        response.on('end', function() {
            log('Result', str);
            var result = JSON.parse(str);

            context.succeed(result);
            log('Result', result);
        });

        response.on('error', serverError);
    };

    https.get(options, callback).on('error', serverError).end();

    log('Discovery Got', 'Got');
}

function handleControl(event, context) {

    if (event.header.namespace === 'Alexa.ConnectedHome.Control') {

        /**
         * Retrieve the appliance id and accessToken from the incoming message.
         */
        var applianceId = event.payload.appliance.applianceId;
        var accessToken = event.payload.accessToken.trim();
        log('applianceId', applianceId);

        var basePath = '';
        basePath = REMOTE_CLOUD_BASE_PATH + '?applianceId=' + applianceId +
            '&request=' + event.header.name;

        var options = {
            hostname: REMOTE_CLOUD_HOSTNAME,
            port: 443,
            path: basePath,
            headers: {
                accept: '*/*'
            }
        };

        var serverError = function (e) {
            log('Error', e.message);
            context.fail(generateControlError(event.header.name,
                'DEPENDENT_SERVICE_UNAVAILABLE',
                'Unable to connect to server'));
        };

        var callback = function(response) {
            var str = '';

            response.on('data', function(chunk) {
                str += chunk.toString('utf-8');
            });

            response.on('end', function() {
                log('done with result');
                var headers = {
                    namespace: 'Alexa.ConnectedHome.Control',
                    name: event.header.name.replace('Request', 'Confirmation'),
                    payloadVersion: '1'
                };
                var payloads = {
                    success: true
                };
                var result = {
                    header: headers,
                    payload: payloads
                };
                log('Done with result', result);
                context.succeed(result);
            });

            response.on('error', serverError);
        };

        https.get(options, callback)
            .on('error', serverError).end();
    }
}

function log(title, msg) {
    console.log('*************** ' + title + ' *************');
    console.log(msg);
    console.log('*************** ' + title + ' End*************');
}

function generateControlError(name, code, description) {
    var headers = {
        namespace: 'Alexa.ConnectedHome.Control',
        name: name,
        payloadVersion: '1'
    };

    var payload = {
        exception: {
            code: code,
            description: description
        }
    };

    var result = {
        header: headers,
        payload: payload
    };

    return result;
}

[Cloud Hostname]改为你自己的服务器域名。这个服务器域名就是你在本地开发的node网站的域名。不要使用127.0.0.1,它对Lambda来说不起作用,你需要将网站发布到因特网上。我在Lambda和Azure web app之间使用了SSL连接,如果你使用Azure可以直接使用[your-app-name].azurewebsites.net,Azure是支持SSL的。如果你使用自己的域名,你需要一张SSL证书。你可以从https://ssl.md为你的域名获取一张免费的证书。

Echo Dot现在还不能同我们的智能灯正确工作,因为Echo Dot还不能理解我们网站给回的信息,而且我们也没有处理discovery指令。

好,让我们来对我们的网站做些修改。现在进入你的网站的app/models文件夹,创建3个文件,分别名为smarthome-discovery.js、smarthome-adddevice.js和smarthome-removedevice.js。

在smarthome-discovery.js里写入如下代码:

function Discovery() {
    var headers = {
        namespace: 'Alexa.ConnectedHome.Discovery',
        name: 'DiscoverAppliancesResponse',
        payloadVersion: '1'
    };

    var payloads = {
        discoveredAppliances: appliances
    };

    var result = {
        header: headers,
        payload: payloads
    };

    return result;
}

module.exports = Discovery;

在smarthome-adddevice.js里写入如下代码:

function AddDevice(manufacturerName, modelName, version, friendlyName, friendlyDescription, actions) {
    if (!friendlyName) {
        return {success: false};
    }
    friendlyName = 'Azure ' + friendlyName;
    var uuid = require('uuid');
    var applianceId = uuid.v4();
    manufacturerName = manufacturerName || friendlyName.replace(/\s+/g, '');
    modelName = modelName || manufacturerName;
    version = version || '1.0';
    friendlyDescription = friendlyDescription || 'No Description';
    actions = actions || ['turnOn', 'turnOff'];
    var appliance = {
        applianceId: applianceId,
        manufacturerName: manufacturerName,
        modelName: modelName,
        version: version,
        friendlyName: friendlyName,
        friendlyDescription: friendlyDescription,
        isReachable: true,
        actions: actions,
        status: 'TurnOff'
    }

    appliances.push(appliance);

    return {success: true};
}

module.exports = AddDevice;

在smarthome-removedevice.js里写入如下代码:

function RemoveDevice(applianceId) {
    var index = -1;
    console.log(appliances)
    appliances.forEach(function(appliance) {
        console.log(appliance.applianceId)
        console.log(applianceId)
        console.log(appliance.applianceId === applianceId)
        index++;
        if (appliance.applianceId === applianceId) {
            appliances.splice(index, 1);
            removed = true;
            return {success: true};
        }
    });
    return {success: false};
}

module.exports = RemoveDevice;

同时,编辑app/controllers文件夹中的home.js文件,在脚本顶部添加如下3个模块:

var Discovery = require('../models/smarthome-discovery.js'),
    AddDevice = require('../models/smarthome-adddevice.js'),
    RemoveDevice = require('../models/smarthome-removedevice.js');

更改smarthome-turnonoff.js已使Echo Dot明白我们已经接受了它所发送的请求:

function TurnOnOff(applianceId, requestType) {
    var headers = {
        namespace: 'Alexa.ConnectedHome.Control',
        name: requestType + 'Confirmation',
        payloadVersion: '1'
    };

    var payloads = {
        success: true
    };
    
    var result = {
        header: headers,
        payload: payloads
    };

    var sendC2DMessage = require('./iot-hub.js')
    new sendC2DMessage({
        applianceId: applianceId,
        request: requestType
    }, 'Alexa');

    appliances.forEach(function(appliance) {
        if (appliance.applianceId === applianceId) {
            appliance.status = requestType;
        }
    });
    
    return result;
}

module.exports = TurnOnOff;
module.exports = TurnOnOff;

更改/api/smarthome路由

router.get('/api/smarthome', function(req, res, next) {
    res.header('Content-Type', 'application/json');
    res.header('Access-Control-Allow-Origin', '*');
    switch(req.query.request) {
        case 'Discovery' :
            res.render('json', {
                json: new Discovery(),
                layout: false
            });
            break;
        case 'AddDevice' :
            res.render('json', {
                json: new AddDevice(
                        req.query.manufacturerName,
                        req.query.modelName,
                        req.query.version,
                        req.query.friendlyName,
                        req.query.friendlyDescription,
                        req.query.actions
                    ),
                layout: false
            });
            break;
        case 'RemoveDevice' :
            res.render('json', {
                json: new RemoveDevice(req.query.applianceId),
                layout: false
            });
            break;
        case 'TurnOnRequest' :
            res.render('json', {
                json: new TurnOnOff(req.query.applianceId, 'TurnOn'),
                layout: false
            });
            break;
        case 'TurnOffRequest' :
            res.render('json', {
                json: new TurnOnOff(req.query.applianceId, 'TurnOff'),
                layout: false
            });
            break;
    }
});

我们使用了一个全局变量appliances来储存添加的设备,你也可以使用数据库来替代它。所以我们需要在网站根目录的app.js中声明appliances:

appliances = [];

不要使用var进行声明,因为我们需要它成为一个全局变量。

万事俱备!让我们现在测试一下!

首先,访问https://[your-website-host]/api/smarthome?request=AddDevice&friendlyName=Test+Device来添加一个设备,你可以看到{"success":true}。接着打开Alexa控制台进入Smart Home标签,点击Discovery devices链接,或者直接对你的Echo Dot说Discover。20秒之后你就可以找到刚刚添加的设备了。

Test Device

现在让我们按照上面的步骤添加三个设备,然后访问https://[your-website-host]/api/smarthome?request=Discovery找出它们的Appliance Id。

Appliance Id

找出这些id后,更改树莓派中的python脚本,替换掉其中的db87ffe4-5d5d-4af7-bb70-da8a43beac901266ab90-b23d-4e0f-83d0-ec162284952f7b38a9f2-c9f4-42cf-bb63-59147eb685b4

现在让Echo Dot忘掉测试的设备,运行树莓派中的python脚本,重新发现设备,然后让Alexa turn on Azure Red Light。

Alexa LED

如果你没有树莓派,你可以从https://github.com/Sneezry/Azure-Smart-Light-Simulator下载我写的叫Azure Smart Light Simulator的Chrome扩展。

Light Simulator

尽情享受使用Azure IoT构建你自己的智能家居设备的乐趣吧!

Comments (3)

  1. mars says:

    Hi, 你好,很高兴看到你的这篇文章,我现在也想利用我的Amazon Echo 来控制家里的灯光设备,按照你的描述 本地客户端与Amazon skill互动的话 中间必须要一个中间的服务器是吗?

    1. Sneezry says:

      是这样的,我使用了一个树莓派来作为bridge。

    2. mars says:

      谢谢你的回答,你使用一个树莓派来做bridge,请问下 你这个bridge是直接与Amazon的AWS连接还是通过Azure IoT Hub中转连接Amazon的AWS?

Skip to main content