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

创建智能家居聊天机器人

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (44投票s)

2016年8月7日

CPOL

42分钟阅读

viewsIcon

83804

downloadIcon

3019

我们使用 Microsoft Bot Framework 和 LUIS 来创建一个基于 Node.js 的聊天机器人,通过 ASP.NET Web API 代理在线提供。提供了一个智能家居系统的软件模拟。同样,也提供了使用 CC3200 和/或传感器标签的硬件模拟教程。

Smart Home Chat Bot

我们使用 Microsoft Bot Framework 和 LUIS 来创建一个基于 Node.js 的聊天机器人,通过 ASP.NET Web API 代理在线提供。提供了一个智能家居系统的软件模拟。同样,也提供了使用 CC3200 和/或传感器标签的硬件模拟教程。

目录

  1. 引言
  2. 背景
  3. 解决方案
    1. 架构
    2. Microsoft Bot Framework 和 LUIS
    3. 技能:组合意图和实体
    4. 配置管理
  4. 使用代码
    1. Bot Connector Proxy 设置
    2. 软件模拟
    3. 单 CC3200 实例
    4. 多个传感器标签
    5. 附加:语音转聊天机器人
  5. 关注点
  6. 参考文献
  7. 历史

引言

自从看了电影《少数派报告》后,我就梦想着能与我的家进行一次真正的对话。基本上,当我想要时,我可以提出诸如“现在的温度是多少?”或“所有门都关好了吗?”之类的查询。此外,我还可以通过“将厨房的温度设为 19°C!”之类的句子主动控制我的家。文本层是最基本的变体。它可以与文本转语音系统(语音识别)一起使用,或者通过基于文本的界面使用。此外,它还可以被其他机器人使用,从而实现无人化计算。

Minority Report
手势界面、柔性显示屏和语音激活是《少数派报告》中的预测内容

在本文中,我们将探讨将这种智能文本识别系统集成到最先进的智能家居系统中的可能性。为此,我们将使用 Microsoft Bot Framework 和 Microsoft Cognitive Services,特别是语言理解智能服务 (LUIS)。我们将只对这些技术进行简要介绍。要获得更详细的介绍,请参考文章 Microsoft Bot Framework 简介

我们将从简要概述我们将要使用的智能家居解决方案开始。在架构部分,我们将概述解决方案架构的细节,然后简要介绍 Microsoft Bot Framework 和 LUIS。然后,我们将花费大部分时间介绍我们的模型和技能适配器。最后,我们将详细介绍如何提供一个完整的模拟,不仅在软件方面,还通过特殊的硬件。在本例中,我们使用了 Texas Instruments 的轻量级 CC3200。

背景

构建一个安全、可靠且性能良好的智能家居平台非常困难。如果我们再增加一个要求,比如覆盖各种设备(连接它们很有趣),那么我们会发现这项任务几乎是不可能完成的。幸运的是,商业系统是存在的。在本文中,我们选择使用 RWE SmartHome 系统。这是德国的市场领导者,以其安全平台而闻名。此外,其设备和服务几乎涵盖了所有可能的用例。

RWE SmartHome 产品附带一个必需的中心单元,称为 SHC(SmartHome Controller 的缩写)。SHC 作为所有集成设备的网关。此网关将所有设备连接到彼此以及后端,并执行规则管理,在满足编程条件时触发操作。我们的聊天机器人服务将与 SHC 通信以读取状态或写入命令。我们将使用标准(UI)客户端通常选择的相同接口。

RWE SmartHome Portfolio
RWE 智能家居设备组合,SHC 居中

除了某些外部第三方设备外,RWE 的智能家居解决方案还提供了一系列核心设备。我们使用散热器恒温器来获取系统中的温度和湿度传感器。这些传感器能够提供有关其状态的信息,例如温度和相对湿度值。此外,散热器恒温器不仅仅是一个传感器——它实际上还包含一个执行器。因此,恒温器可以用于改变室温,因为它们安装在散热器上。

此外,我们还可以使用所谓的门窗传感器来检测是否有任何门窗仍然打开。借助可插拔开关,我们可以控制任何类型的照明。最后,运动检测器使我们能够识别运动。正如我们所见,可能性似乎无穷无尽。我们所要做的就是获取和转换信息。

为了与网关通信,我们需要与 RWE 运营的后端通信。这里提供了 RESTful Web API 或 OAuth 2 身份验证等标准。我们只需要一个已注册的客户端。API 还提供了一个 WebSocket 通道来监听事件。然而,在本文的这个阶段,我们假设只执行被动操作(回复请求)。不采取主动操作(告知状态更改)。

解决方案

现在我们了解了我们的聊天机器人使用的(真实)智能家居解决方案,我们需要经历创建智能家居聊天机器人系统的步骤。我们从高层架构开始,然后深入到实现。在接下来的部分中,我们将介绍为我们的聊天机器人提供另一种智能家居解决方案——无论是软件模拟,还是使用单个 TI CC3200 或通过蓝牙连接的一些 TI SensorTags 的非常简单且基础的 DIY 硬件设置。

架构

我们的架构如下所示。我们从一个运行在 Raspberry Pi 上的本地聊天机器人客户端开始。我们使用 Raspberry Pi,因为它足够强大,可以轻松运行 Node.js。此外,由于 Pi 上运行着完整的 Linux 发行版,该解决方案可以扩展到许多其他内容,例如监控通用 Web 资源、检查本地文件服务器或提供一些多媒体流和信息。实际上,Raspberry Pi 的功耗不高,非常适合连续运行。我们将使用 Raspberry Pi 3,因为它集成了 WiFi 和蓝牙。后者将在本文稍后用于通过低功耗蓝牙 (BTLE) 连接 TI SensorTags。

本地聊天机器人客户端通过 WebSocket 通道连接到我们的中央聊天机器人代理。连接代理托管在 Microsoft Azure 中。它提供了可供真实 Microsoft Bot Connector 使用的回调以及本地聊天机器人客户端的通道注册。WebSocket 具有持久通信通道的优势,这使我们能够发送和检索信息。由于只知道带有 DNS 名称的聊天机器人代理,我们需要一种方法来打开一个未Known地址和Known地址之间的连接。

还有另一方我们需要连接。本地聊天机器人客户端也连接到智能家居系统。对于这部分的配置,我们在 Raspberry Pi 内托管一个小 Web 服务器。我们将在几分钟内详细介绍它。

因此,高层架构图如下所示。紫色组件由 Microsoft 提供,浅蓝色组件是第三方组件。我们自己的组件填充了较深的蓝色。

Smart Home Chat Bot Architecture
解决方案架构图

仅客户端(例如 Skype for Windows)与其关联的后端(例如 Skype)之间的连接类型仍然未知。但是,我们也不关心这一点。Microsoft Bot Framework 负责聚合*任何*类型的外部系统(目前列表有限,但将来会添加更多通道)。

因此,最终我们只关心以下两个组件。代理提供一个 Bot Framework 回调的终结点。此外,它还提供另一个终结点,用于与本地聊天机器人客户端建立 WebSocket 连接。最后,它显式使用 HTTP 查询 LUIS,尽管这可以(并且将会)被抽象成一个接口。

Smart Home Chat Bot Components
解决方案的自定义组件

另一方面,本地聊天机器人客户端通过 WebSocket 连接到代理,并使用内部接口收集有关家庭的信息。此接口的主要实现是连接到 RWE SmartHome 解决方案,但在本文中,我们还将探讨几种其他可能性(并展示实现这些可能性的代码)。我们将看到提供的架构给了我们很大的灵活性。

深入挖掘,我们发现之前提到的组件的架构。我们将从代理开始。代理由放置在自己控制器中的两个终结点组成。消息控制器的目的很简单,就是充当 Microsoft Bot Connector 的一个proper回调。这意味着我们需要接收消息并确定如何处理它。本质上,一切都归结为将来自某个频道中某个用户 Thus 的消息映射到已注册的本地聊天机器人客户端。一旦成功,我们就可以开始使用 LUIS 对消息进行评估。

下图显示了一个依赖项解析器,用于提供通过注册表查找连接或通过 LUIS 执行评估的服务。

Smart Home Chat Bot Connector Proxy Architecture
Microsoft Bot Connector Proxy 架构图

此架构中的一个重要方面是包含了一个脚本引擎,MAGES。有关 MAGES 的详细信息,请阅读文章 MAGES - .NET 的终极脚本。我们使用 MAGES 来避免使用另一个具有任意数量参数的工厂和多个反序列化问题。相反,本地聊天机器人客户端只需要发送一条消息来触发运行正确的 MAGES 命令。这可以与允许外部方执行 shell 命令相提并论。然而,安全风险要小得多,因为 MAGES 允许我们轻松构建一个沙箱。

总而言之,代理按定义应该是相当轻量级的。它的唯一目的是将传入消息转发给相应的聊天机器人连接器。此外,它已经执行了一些中间任务,例如评估查询以处理其传输的意图和实体。

那么本地聊天机器人客户端的架构又是怎样的呢?下图回答了这个问题。

Smart Home Local Chat Bot Client Architecture
本地聊天机器人客户端架构图

应用程序以聊天机器人客户端为中心。此部分在首先读取配置并采取适当步骤后启动所有组件。最重要的是,所有使用的组件都通过事件间接连接在一起。这种松耦合对于在不需要整个应用程序的情况下测试单个组件非常重要。最重要的流程是使用 ProxyManager 触发的 SmartHome 组件以及技能适配器。技能适配器将在稍后介绍。简而言之,技能适配器使用 SmartHome 组件提供的接口将意图(例如,get-temperature)映射到相应的操作。

已经提到本地聊天机器人客户端也运行一个 Web 服务器。此 Web 服务器仅用于快速信息和配置管理。它没有其他用途。

为了实现高度灵活性,系统附带一个命令行解析器,以便可以使用不同的配置或详细程度级别启动应用程序。

Microsoft Bot Framework 和 LUIS

我们在引言中已经概述了本文不会对 Microsoft Bot Framework 或 LUIS 进行详细介绍。尽管如此,由于这两个是关键组件,我们应该仔细研究它们。

我们的代理是用 C# 编写的,使用了 ASP.NET Web API 框架以及 Microsoft Bot Builder 库的最新版本之一。/api/messages 终结点响应 POST 请求,通过调用以下操作。请求正文将被序列化为 Activity 实例。

public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
    if (activity.Type == ActivityTypes.Message)
    {
        /* ... see below ... */
    }
    else
    {
        SystemMessageHandlers.Handle(activity);
    }

    return Request.CreateResponse(HttpStatusCode.OK);
}

处理系统消息

最重要的流程显然涉及标准消息。系统消息通常被通用处理。这里,我们可以如下处理它们。不过,通常我们还应包括一些用户管理,一旦对话发生变化(例如,新参与者加入或现有参与者离开)或用户离开聊天时,用户管理就会发挥作用。在本文中,我们不处理会话持久性和旧实体,即,我们可以安全地忽略这些系统消息而不会出现任何问题。

public static class SystemMessageHandlers
{
    private static readonly Dictionary<String, Func<Activity, Activity>> handlers = new Dictionary<String, Func<Activity, Activity>>
    {
        { ActivityTypes.DeleteUserData, activity => null },
        { ActivityTypes.ConversationUpdate, activity => null },
        { ActivityTypes.ContactRelationUpdate, activity => null },
        { ActivityTypes.Typing, activity => null },
        { ActivityTypes.Ping, activity => null },
    };

    public static Activity Handle(Activity message)
    {
        var handler = default(Func<Activity, Activity>);

        if (handlers.TryGetValue(message.Type, out handler))
        {
            return handler.Invoke(message);
        }

        return null;
    }
}

谈到用户消息,是时候仔细看看标准消息的处理了。上面代码片段中缺少以下代码

var serviceUrl = new Uri(activity.ServiceUrl);
var connector = new ConnectorClient(serviceUrl);
var connection = _registry.GetFor(activity.ChannelId, activity.From.Id);

if (connection != null)
{
    await AskQuestionAsync(activity, serviceUrl, connection);
}
else
{
    await TryRegisterAsync(activity, connector);
}

现在这变得有趣了!我们首先尝试查找给定帐户的注册。如果成功,我们可以继续提问。否则,我们将尝试为该客户端执行注册。注册流程是我们实现中的一个棘手问题。

有多种方法可以将提供程序注册到本地聊天机器人。我们发现

  • 我们可以直接在本地聊天机器人的配置部分输入提供程序(通道名称 + 帐户 ID)
  • 我们可以使用与智能家居连接相同的用户名/密码组合
  • 我们可以使用在本地聊天机器人上生成的单次令牌

我们决定选择后者。为什么?第一个选项可能最简单,但是,可能不知道帐户 ID(例如,对于 Skype,这是一个相当大的数字,我从未在其他地方见过)。因此,此选项被排除。第二个选项似乎非常不安全。考虑在同步到所有设备(具有持久历史记录!)的聊天中以明文(!)输入用户名和密码!太可怕了……第三个选项相当安全。我们只能通过访问(本地可用)配置网站来获取单次令牌。代码随后会在对话中可见(是的,不安全),但由于一旦旧令牌被使用就会生成新令牌,因此这不是一个大问题。

MAGES 集成

最后,我们需要提供撤销先前授予访问权限的选项。这将在本地聊天机器人上完成。我们已经描述了本地聊天机器人只发送一个命令类消息给代理。这些命令只能使用以下函数。我们删除了任何全局 API。绑定和所有其他内容都在 MAGES 中完成。

static class AdapterApi
{
    public static void Register(String id, String shcSerial)
    {
        // ...
    }

    public static void Update(String id, String authCode)
    {
        // ...
    }

    public static void Link(String id, String provider, String account)
    {
        // ...
    }

    public static void Unlink(String id, String provider, String account)
    {
        // ...
    }

    public static void Answer(String id, String request, String answer)
    {
        // ...
    }
}

为了完成这个图景,我们展示了绑定是如何实际实例化的。我们将看到没有包含全局函数(如果有,我们将通过白名单显式实现它们;黑名单存在许多安全问题,不应使用)。AdapterApi 类用作函数的基类——直接包含,不带命名空间。

sealed class MagesScriptingHost : IScriptingHost
{
    private readonly Engine _engine;

    public MagesScriptingHost()
    {
        var scope = new Dictionary<String, Object>();
        _engine = new Engine(new Configuration
        {
            IsEngineExposed = false,
            IsEvalForbidden = true,
            IsThisAvailable = true,
            Scope = new ReadOnlyDictionary<String, Object>(scope)
        });

        _engine.Globals.Clear();
        _engine.SetStatic(typeof(AdapterApi)).Scattered();
    }

    public void Execute(String command)
    {
        try { _engine.Interpret(command); }
        catch (Exception ex) { Trace.TraceError(ex.Message); }
    }
}

如果命令失败,它可能——在最坏的情况下——破坏 WebSocket 连接。我们可以假设这种致命命令只能来自外部/不受控制的本地聊天机器人客户端。在这种情况下,我们可以接受给定 WebSocket 连接的中断。但是,我们决定捕获所有客户端的错误,并让 WebSocket 连接在发送此类致命命令时得以保留。

LUIS 评估

回到最初的话题,我们仍然需要讨论 LUIS 评估何时被处理。我们已经发现了两种可以用于传入消息的路径。我们发现 AskQuestionAsyncTryRegisterAsync 将接管。后者已经讨论过。现在是时候阐明前者了。

AskQuestionAsync 使用 IEvaluator 实例来执行消息评估。然后将结果发送到先前检索到的连接。为了使用 LUIS 进行此评估,需要考虑 LuisEvaluator。实现如下所示。

public sealed class LuisEvaluator : IEvaluator
{
    private static readonly String ApplicationId = WebConfigurationManager.AppSettings["LuisApplicationId"];
    private static readonly String SubscriptionId = WebConfigurationManager.AppSettings["LuisSubscriptionId"];

    public async Task<String> EvaluateAsync(String input)
    {
        var query = Uri.EscapeDataString(input);

        using (var client = new HttpClient())
        {
            var uri = $"https://api.projectoxford.ai/luis/v1/application?id={ApplicationId}&subscription-key={SubscriptionId}&q={query}";
            var msg = await client.GetAsync(uri);

            if (msg.IsSuccessStatusCode)
            {
                return await msg.Content.ReadAsStringAsync();
            }
        }

        return null;
    }
}

我们没有采取任何措施将 JSON 反序列化为对象。我们对字符串表示满意,因为它可以直接发送到等待输入的 Raspberry Pi 上的本地聊天机器人客户端。

AskQuestionAsync 方法的实现还向我们展示了另一件有趣的事情:由于通信是真正异步的(即,我们不使用异步状态机制来持有实例并稍后返回,但实际上我们不知道是否有(以及如何)对我们的请求有响应),因此我们需要提供一种方法来识别请求的类型以供以后使用。这很重要,因为请求决定了答案的通道。

以下代码片段包含所有重要信息。创建一个新的 RequestInfo 对象来保存有关已执行请求的信息。请求将被缓存一段时间(例如,5 分钟),然后将其从缓存中删除。这意味着我们无法获得超过 5 分钟的查询答案——保证!

private async Task AskQuestionAsync(Activity activity, Uri serviceUrl, ConnectionInfo connection)
{
    var message = activity.Text ?? String.Empty;
    var id = Guid.NewGuid();
    var evaluation = await _evaluator.EvaluateAsync(message);
    var request = new RequestInfo
    {
        User = activity.From,
        Bot = activity.Recipient,
        Conversation = activity.Conversation,
        ServiceUrl = serviceUrl
    };

    await connection.SendRequestAsync(id, request, evaluation);
}

此时,仔细查看 LUIS 项目定义是有意义的。

LUIS 项目

LUIS 项目可以(即导出)为一个 JSON 文件。该文件包含代表我们模型的所有信息。对于附带本文的源代码,说明一个由单个意图和可选实体组成的简单模型就足够了。当然,普遍的趋势仍然是正确的;我们需要足够的意图(即数据点)才能获得 LUIS 提供的识别的准确性。

在最简单的情况下(一个获取温度的意图,以及一个可选的位置实体),我们会得到一个项目定义,其中包含一组显而易见的意图,类似于以下内容。

{
  "luis_schema_version": "1.3.0",
  "name": "SmartHomeChatBot",
  "desc": "Language understanding for a smart home platform.",
  "culture": "en-us",
  "intents": [
    {
      "name": "None"
    },
    {
      "name": "get-temperature"
    }
  ],
  "entities": [
    {
      "name": "location"
    }
  ],
  "composites": [],
  "bing_entities": [
    "temperature",
    "number",
    "datetime"
  ],
  "actions": [],
  "model_features": [],
  "regex_features": [],
  "utterances": [
    {
      "text": "get the temperature.",
      "intent": "get-temperature",
      "entities": []
    },
    {
      "text": "get the temperature in the bathroom.",
      "intent": "get-temperature",
      "entities": [
        {
          "entity": "location",
          "startPos": 5,
          "endPos": 5
        }
      ]
    },
    {
      "text": "what's the temperature.",
      "intent": "get-temperature",
      "entities": []
    },
    {
      "text": "temperature in the kitchen.",
      "intent": "get-temperature",
      "entities": [
        {
          "entity": "location",
          "startPos": 3,
          "endPos": 3
        }
      ]
    },
    {
      "text": "tell me the temperature in the living room.",
      "intent": "get-temperature",
      "entities": [
        {
          "entity": "location",
          "startPos": 6,
          "endPos": 7
        }
      ]
    },
    {
      "text": "show me the temperature.",
      "intent": "get-temperature",
      "entities": []
    },
    {
      "text": "temperature in my office",
      "intent": "get-temperature",
      "entities": [
        {
          "entity": "location",
          "startPos": 3,
          "endPos": 3
        }
      ]
    },
    {
      "text": "what's your name",
      "intent": "None",
      "entities": []
    },
    {
      "text": "this sentence is bogus",
      "intent": "None",
      "entities": []
    },
    {
      "text": "no relevance to anything",
      "intent": "None",
      "entities": []
    },
    {
      "text": "foobar",
      "intent": "None",
      "entities": []
    },
    {
      "text": "how warm is it in the bathroom",
      "intent": "get-temperature",
      "entities": [
        {
          "entity": "location",
          "startPos": 6,
          "endPos": 6
        }
      ]
    },
    {
      "text": "ho",
      "intent": "None",
      "entities": []
    },
    {
      "text": "give me the temperature",
      "intent": "get-temperature",
      "entities": []
    },
    {
      "text": "what's the temperature in the kitchen?",
      "intent": "get-temperature",
      "entities": [
        {
          "entity": "location",
          "startPos": 7,
          "endPos": 7
        }
      ]
    },
    {
      "text": "temperature in the living room",
      "intent": "get-temperature",
      "entities": [
        {
          "entity": "location",
          "startPos": 3,
          "endPos": 4
        }
      ]
    },
    {
      "text": "sorry",
      "intent": "None",
      "entities": []
    },
    {
      "text": "temperature at home?",
      "intent": "get-temperature",
      "entities": []
    },
    {
      "text": "what's the temperature",
      "intent": "get-temperature",
      "entities": []
    },
    {
      "text": "foo",
      "intent": "None",
      "entities": []
    },
    {
      "text": "hi",
      "intent": "None",
      "entities": []
    },
    {
      "text": "how hot is it ?",
      "intent": "get-temperature",
      "entities": []
    },
    {
      "text": "tell me the temperature",
      "intent": "get-temperature",
      "entities": []
    },
    {
      "text": "what's the temperature?",
      "intent": "get-temperature",
      "entities": []
    }
  ]
}

总的趋势是通过提供更多意图来提高准确性。尽管如此,随着我们增加意图的数量,为了保持准确性稳定,所需的意图数量将呈指数级增长。实际上,这并不是什么大问题,因为最高命中(无论边际如何)都可以——根据定义——被认为是正确的选择。因此,我们不应该太在意保持准确性稳定,而应该关注我们是否得到了*正确*的结果。一旦我们将来发现 LUIS 评估中出现不正确的结果,我们应该添加更多意图来平衡系统,使其对我们有利。

实体数量在很大程度上取决于这些意图的复杂性。通常,我们希望尽可能多地使用预定义实体,但是,即使列表相当详尽,我们也可能找不到我们想要的实体。实体比意图更容易学习和识别,应该被视为二阶问题。

现在我们将切换到本地聊天客户端。我们将从仔细查看技能适配器开始。

技能:组合意图和实体

来自代理的任何类型的查询都将作为 LUIS 评估的结果传输到客户端。因此,我们将使用一个特殊的适配器来处理此类请求。我们称之为技能适配器。本质上,它将意图映射到 JavaScript 模块,并使用智能家居适配器和给定的实体调用从模块导出的函数。

以下代码显示了技能适配器的外观。它是一个简单的类,需要一个智能家居适配器来创建。技能的解析发生在 resolve 方法中。这里发生了一些魔术。它归结为查找 skills 子文件夹中与给定意图名称(这是我们的约定)匹配的 JavaScript 文件。并调用相应的函数。

最重要的是,我们只允许使用缓冲的模块,前提是它们仍然是最新的。否则,我们将清除缓存并重新加载相应的模块。

const fs = require('fs');
const util = require('util');
const helpers = require('./helpers.js');
const EventEmitter = require('events').EventEmitter;

class Skills extends EventEmitter {
  constructor (shc) {
    super();
    this.table = { };
    this.shc = shc;
  }

  resolve (id, intent, entities) {
    const fn = __dirname + '/skills/' + intent + '.js';

    if (!intent || !fs.existsSync(fn)) {
      return this.emit('failure', id);
    }

    const stats = fs.statSync(fn);
    const changed = new Date(util.inspect(stats.mtime));

    if (!this.table[intent] || changed > this.table[intent].changed) {
      this.table[intent] = {
        changed: changed,
        execute: helpers.getModule(fn)
      };
    }

    this.table[intent].execute(this.shc, entities).then((message) => {
      this.emit('success', id, message);
    }).catch((err) => {
      return this.emit('failure', id);
    });    
  };
};

module.exports = Skills;

我们使用 EventEmitter 来利用标准化的事件发出方式(无需我们付出太多努力)。现在的问题是:这样的技能是什么样的?

在以下代码片段中,我们看到了 get-temperature 技能的定义。我们可以写得更短(或者当然,长得多),但给定的版本显示了最重要的功能。我们使用给定的智能家居适配器实例异步检索所有温度状态。一旦值已知,我们就需要查看是否要求了任何特定房间。如果是,我们过滤结果集。

对于生产级代码,我们可能希望通过将房间与已知位置列表进行匹配(并执行相似性检查,如果给定房间与列表中的任何条目都不匹配)来增加鲁棒性。在这里,我们仅记录未匹配的情况。

const Promise = require('promise');

module.exports = function (shc, entities) {
  return new Promise((resolve, reject) => {
    shc.getAllStates('Temperature').then(function (temperatures) {
      const sentences = [];

      if (entities.length > 0) {
        const original = temperatures;
        temperatures = [];

        entities.forEach(function (entity) {
          if (entity.type === 'location') {
            const location = entity.name.toUpperCase();
            temperatures = temperatures.concat(original.filter(function (temperature) {
              return temperature.location && temperature.location.toUpperCase() === location;
            }));
          }
        });
      } else {
        const sum = temperatures.reduce((c, nxt) => c + nxt.state.value, 0);
        sentences.push('The average temperature is ' + (sum / temperatures.length) + '°C.');
      }

      const values = temperatures.map(function (temperature) {
        return [temperature.state.value, '°C in ', temperature.location, ' (', temperature.device.name, ')'].join(''); 
      });

      if (values.length > 0) {
        const message = values.join(', ');
        sentences.push('The temperature is ' + message + '.');
      }

      if (sentences.length === 0) {
        sentences.push('No temperature sensor readings found.');
      }
        
      return resolve(sentences.join(' '));
    }).catch(function (err) {
      return reject(err);
    });
  });
};

因此,我们要么返回包含一些评估(我们在这里使用了所有温度的平均值)的所有温度的完整集合,要么仅回答已识别为实体的房间。

关于技能最好的地方在于它们可以在运行时扩展(或改进)。应用程序启动时没有固定的绑定。随着更多意图被建模,系统可能会发展。这对于原型设计或稍后定义的更新过程来说非常棒。

配置管理

为了简化开发,建立了一个既简单又灵活的配置管理系统。主要思想是将所有重要设置聚合到一个 JSON 文件中,并在另一个文件中专门化*一些*设置。虽然第一个文件始终被加载,但后者仅在请求时加载(即,如果存在给定名称的文件)。特殊文件中的设置会覆盖(或扩展)主文件中的设置。

这种专门化称为环境。因此,通用设置文件是全局文件,专门化文件是环境(或本地)文件。环境文件遵循命名约定,即其文件名(不带扩展名)以 .environmentname 结尾,其中 environmentname 表示所选的环境名称。对于开发,使用了两种专门化:debugrelease(合理且熟悉的选择)。

下图说明了这种配置管理方式。

Smart Home Local Chat Bot Client Configuration
本地聊天机器人客户端的配置管理

因此,我们可以使用以下全局文件

{
  "adapter": {
    "host": "smarthomebot.azurewebsites.net"
  },
  "webserver": {
    "port": 3000,
    "assets": "assets",
    "views": "views"
  },
  "smarthome": { }
}

并结合,例如,用于 debug 环境的专门化

{
  "adapter": {
    "host": "localhost:3979"
  }
}

其思想很清楚:而不是连接到生产代理(部署在 Microsoft Azure 数据中心),我们连接到本地运行的版本(主要用于调试目的)。同样,我们可以覆盖其他设置,但这些设置似乎与调试/发布问题耦合程度较低。

另一个提到的是用户配置。本地聊天机器人客户端默认映射到一个用户。但是,该用户可能拥有已注册的不同帐户(在不同的频道中,例如 Skype、Slack 等)。userData 文件绑定到环境,因为不同的环境可能使用不同的连接 URL 和智能家居凭据。因此,使用给定环境专门化用户配置是有意义的。

(分层)配置和用户数据之间还有另一个重要区别。配置只是读取且从不修改(来自应用程序),而用户数据通常由应用程序独家填充。因此,这是应用程序用于存储有关其用户持久信息的文件的名称。这些信息在应用程序重新启动时用于继续之前的状态。此文件还将存储用于获取智能家居系统访问令牌的刷新令牌(参见 OAuth 2 流程)。

Smart Home Client Authorization
身份验证客户端使用智能家居系统的流程

存储刷新令牌(未加密)存在安全风险。对于将要部署的客户端,刷新令牌绝不应以未加密的方式存储。

总而言之,大部分配置都是在服务器实际运行时通过 Web 浏览器完成的。这里的以下包装器至关重要。通常,我们仍然希望在应用程序其余部分和 Web 服务器之间保持松耦合。因此,我们通过事件传递模型。然而,路由是此抽象层的一部分。

以下代码显示了使用的 WebServer 类。对于渲染,使用了 JADE 视图引擎。

const express = require('express');
const bodyParser = require('body-parser');
const EventEmitter = require('events').EventEmitter;

class WebServer extends EventEmitter {
  constructor (config) {
    super();
    const app = express();
    app.use(express.static(__dirname + '/../' + config.assets));
    app.use(bodyParser.urlencoded({ extended: true }));
    app.set('views', __dirname + '/../' + config.views);
    app.set('view engine', 'jade');

    app.get('/', (req, res) => {
      const model = { };
      this.emit('index', model);
      res.render('index', model);
    });

    app.post('/revoke', (req, res) => {
      const data = req.body;
      const model = { data: data };
      this.emit('revoke', model);
      res.redirect('/');
    });

    app.post('/setup', (req, res) => {
      const data = req.body;
      const model = { data: data };
      this.emit('setup', model);
      res.render('setup', model);
    });

    app.listen(config.port, () => {
      console.log('Webserver started at port %s!', config.port);
    });
  }
};

module.exports = WebServer;

这结束了我们聊天机器人中的配置管理。

Local Chat Bot Client Webserver
我们本地聊天机器人客户端的配置 Web 服务器

上面的截图也显示了配置概览中显示的一次性令牌。

使用代码

到目前为止描述的代码应该可以从一开始就使用。但是,如前所述,我们可能需要为智能家居采用另一个提供程序。因此,该解决方案不附带 RWE SmartHome 解决方案的提供程序。相反,源提供了通用的软件提供程序和两个可以使用的硬件提供程序。

选择哪个提供程序由读者决定。我们设计了系统,使其可以通过配置进行提供。这意味着编写自定义提供程序也相对容易,该提供程序可以与现有的家庭自动化跟踪器(或任何其他类型的工具)一起使用。

现在我们将深入探讨使提供的解决方案正常工作的所有主题。

Bot Connector Proxy 设置

Bot Connector Proxy 的源文件以 ASP.NET Web API 项目的形式提供。要部署或测试给定的源文件,我们需要在 web.config 中输入一些必需的参数。文件最初看起来如下

<configuration>
  <appSettings>
    <add key="BotId" value="" />
    <add key="MicrosoftAppId" value="" />
    <add key="MicrosoftAppPassword" value="" />
    <add key="LuisApplicationId" value="" />
    <add key="LuisSubscriptionId" value="" />
  </appSettings>
  <!-- ... -->
</configuration>

部署需要填写五个字段。否则,如果我们只对玩弄该解决方案感兴趣,我们就需要至少提供 LuisApplicationIdLuisSubscriptionId 键的值。如果我们想在没有 LUIS 集成的情况下玩弄该解决方案,我们可以通过更改依赖项解析器使用的容器中的注册服务来实现。

所以请注意,当前添加的 LuisEvaluator 实际上使用了这些密钥。没有它们,解决方案将无法工作。

软件模拟

任何智能家居适配器都需要满足以下基本接口

interface SmartHomeAdapter {
  refresh(code: string): Promise;
  login(code: string): Promise;
  getAllStates(name: string): CapabilityState[];
  getDevice(capabilityId: string): Device;
  getLocations(device: Device): string[];
}

我们不会详细介绍自定义类型的细节,但它们的目的应该仅从其名称中可见。我们的软件模拟应该足够简单,也可以用于例如单元测试(或任何类型的集成测试),但也要足够灵活以覆盖涉及其他硬件的场景(从而简化其集成)。因此,软件模拟是即将到来的小工具的先决条件。

通常,smarthome 部分(用于 RWE SmartHome 系统)看起来类似于以下代码片段

"smarthome": {
  "type": "rwe",
  "baseUrl": "/* API URL */",
  "clientId": "/* id */",
  "clientSecret": "/* secret */",
  "redirectUrl": "/* Redirect URL */"
}

type 很重要。它标记要使用的实际适配器。对于我们的软件模拟,我们可以选择 virtual。在最简单的情况下,虚拟适配器看起来如下

const Promise = require('promise');

class SmartHomeClient {
  constructor (config) {
    this.loggedIn = false;
  }

  getAllLocations () {
    return Promise.resolve({
      // ...
    });
  }

  getAllDevices () {
    return Promise.resolve({
      // ...
    });
  }

  getAllCapabilities () {
    return Promise.resolve({
      // ...
    });
  }

  getAllCapabilityStates () {
    return Promise.resolve({
      // ...
    });
  }

  authorizeWithRefresh (refreshToken) {
    this.loggedIn = true;
    return Promise.resolve({
      refresh: refreshToken,
      expires: 172800,
      shc: { serialnumber: '123456789' }
    });
  }

  authorize (code) {
    return authorizeWithRefresh(code);
  }
}

module.exports = SmartHomeClient;

现在我们想增加一些内容使其更令人愉快。我们可以使用配置来传输预定义的设备、位置和功能集。此外,我们还可以传输一个模块的名称来加载以检索数据。这样,我们就可以使用虚拟适配器不仅作为模拟,还可以用于真实通信,例如与本地系统上写入的文件进行通信。这使得集成下面描述的硬件成为可能。

大部分灵活性来自一个本质上非常简单的函数。此函数通过已经接收到一个或通过字符串传递的实体/给定函数构建一个解析器。

function buildResolver (resolver, entities) {
  if (typeof resolver === 'string') {
    resolver = eval(resolver);
  } else if (resolver && typeof resolver !== 'function') {
    entities = resolver;
    resolver = undefined;
  }

  if (!resolver) {
    if (entities) {
      resolver = () => entities;
    } else {
      resolver = () => [];
    }
  }

  return resolver;
}

上述函数在构造函数中使用。例如,对于解析设备,该函数可以如下使用。

constructor (config) {
  /* ... */
  this.deviceResolver = buildResolver(config.deviceResolver, config.devices);
}

默认情况下,配置中不写入任何内容。因此,我们不期望在任何地方收到任何内容。但这不成问题。为了动态获取函数定义,我们还引入了一些有用的函数,以便在同一上下文中可用。

function readLinesFromFile (fn) {
  const data = fs.readFileSync(fn, 'utf8');
  return data.trim().split('\n');
}

function readLastLineFromFile (fn) {
  const lines = readLinesFromFile(fn);
  return lines.slice(-1)[0];
}

function readFileAsJson (fn) {
  const data = fs.readFileSync(fn, 'utf8');
  return JSON.parse(data);
}

有了这些助手,我们基本上可以在任何地方立即通过虚拟适配器实现所有目标。

单 CC3200 实例

软件模拟是一个很好的(且易于控制的)玩弄智能家居聊天机器人解决方案的可能性。但是,如果我们想利用智能家居聊天机器人,我们需要将其连接到真实传感器。即使最佳解决方案是使用前面描述的系统(RWE SmartHome),我们可能也不想在这种系统上花费很多钱。也许本地市场没有好的系统(RWE SmartHome 目前仅在欧洲中部提供),或者可用的解决方案不提供任何 API。在这些情况下,我们可能希望使用一个轻量级解决方案作为替代。

CC3200 是一个强大的微控制器单元 (MCU),也可在 SDK 板上找到。该板称为 CC3200-LaunchXL。为了简化开发,该板已经提供了一些扩展的可能性和一些集成传感器。其中一个传感器是温度传感器。非常适合我们的情况!

接下来,我们将介绍设置 CC3200 开发板并用传感器读数和周期性发送到本地服务器(本地聊天机器人正在运行的地方)对其进行编程所需的步骤。

要求

要使用 CC3200 获取传感器数据(例如,使用其内置温度传感器或其他外部连接的传感器),如本教程所述,我们需要以下设置

  • TI CC3200-LaunchXL 板
  • 一根 USB 微型电缆
  • 已安装的 Energia IDE
  • CC3200 的 WiFi 互联网接入

我们可以使用 Energia 提供的编辑器或任何其他编辑器,例如 Sublime Text、Emacs 或 vim。

连接 LaunchPad

最简单的方法是在 Energia 中按 CTRL + M。这将编译二进制文件、上传代码、打开串行监视器并运行应用程序。然而,根据设计,LaunchPad 没有提供一种非常程序员友好的方法来进行快速构建-测试-调试循环。需要设置不同的跳线才能从运行模式切换到编程模式再回来。

下图显示了一种使用 J8 (TOP) 连接器和 SOP2 (BOTTOM) 的便捷的变通方法。

CC3200 Jumper
用于快速执行的 CC3200 跳线

以下是详细的分步说明

  1. 我们首先移除 J8 和 SOP2 跳线(如果已安装)。
  2. 将 USB 连接器向上放置,我们将跳线的一侧连接到 J8 的顶部,另一侧连接到 SOP2 的底部。
  3. 现在当我们通过 CTRL + M 从 Energia 运行代码时,我们将看到串行监视器弹出(确保配置了正确的波特率!),并且 LED 开始闪烁。

此时,我们的设置看起来类似于下一张图片。

CC3200 LEDs
一些 CC3200 LED 正在闪烁

到目前为止一切顺利。下一步是使其更具交互性。我们可以使用其中一个按钮,

  • PUSH1 (3)
  • PUSH2 (11)

或来自集成传感器的一些信息。对于按钮,我们将使用 digitalRead(参见参考)来获取状态(HIGHLOW)并不断通过轮询监控当前状态。按钮很无聊,所以我们马上进行传感器输入。

发送 HTTP 请求

到目前为止,我们已经取得了很大的成就。我们从一种简单的“Hello World”开始,达到了能够收集传感器信息并根据收集到的信息触发输出的阶段。这正是微控制器的原始目的——读取硬接线传感器,处理输入并相应地控制硬接线执行器。添加无线连接,特别是到万维网,开启了一个全新的可能性宇宙,并实现了 IoT 应用。现在我们要迈出这一关键一步。

我们可以使用 httpbin.org 向其 API 发出请求,例如获取一个整数。然后使用闪烁的 LED 显示生成的数字。每次 HTTP 调用后会插入五秒钟的暂停(LED 熄灭)。

在我们开始 HTTP 调用之前,我们需要建立网络连接。幸运的是,CC3200 LaunchPad 带有板载 WiFi 芯片。

与集成加速度计时类似,我们将代码放在一个新的文件 wifi.cpp 中。标头非常简单。这里我们只声明一个函数。

#pragma once

void connectWifi(char* ssid, char* password);

源文件包含相当多的调试打印语句,这有助于识别设置过程中发生的情况。为了使其完全正常工作,我们使用 Energia 附带的 WiFi 库。

#include "wifi.h"
#include <Energia.h>
#include <WiFi.h>

void connectWifi(char* ssid, char* password) {
  Serial.print("Connecting to WIFI network ");
  Serial.print(ssid);
  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(300);
  }

  Serial.println(" connected!");
  Serial.print("Waiting for an IP address ");

  while (WiFi.localIP() == INADDR_NONE) {
    Serial.print(".");
    delay(300);
  }

  Serial.println(" received!");
}

这里包含的步骤很简单。我们首先连接到 WiFi 网络。然后我们等待连接建立。最后,我们也等待路由器分配给我们一个 IP。此时,WiFi 连接已建立并准备好使用。

我们现在如何进行 HTTP 请求?嗯,事实证明这并不那么容易!一开始,我们的 API 就像下面的声明一样简单

bool httpGetRequest(char* hostname, char* path);

我们只传递一个主机名,例如 httpbin.org,和一个路径。在我们的例子中,我们选择 /bytes/4 来获取 4 个随机字节。处理函数源代码与下面所示的代码非常接近。使用的许多函数都来自 SimpleLink 库,您可以在出色的文档中找到它们。

#include <Energia.h>
#include <WiFi.h>

bool httpGetRequest(char* host, char* path) {
  String hostname = String(host);
  String head_post = "GET " + String(path) + " HTTP/1.1";
  String head_host = "Host: " + hostname;
  String request = head_post + "\n" +
                   head_host + "\n\n";

  char receive_msg_buffer[1024];
  uint32_t host_ip;
  bool success = false;

  SlTimeval_t timeout { .tv_sec = 45, .tv_usec = 0 };

  if (sl_NetAppDnsGetHostByName((signed char*)hostname.c_str(), hostname.length(), &host_ip, SL_AF_INET)) {
    return false;
  }

  SlSockAddrIn_t socket_address {
    .sin_family = SL_AF_INET, .sin_port = sl_Htons(80), .sin_addr = { .s_addr = sl_Htonl(host_ip) }
  };

  uint16_t socket_handle = sl_Socket(SL_AF_INET, SL_SOCK_STREAM, IPPROTO_TCP);

  if (sl_SetSockOpt(socket_handle, SL_SOL_SOCKET, SL_SO_RCVTIMEO, (const void*)&timeout, sizeof(timeout)) >= 0 &&
      sl_Connect(socket_handle, (SlSockAddr_t*)&socket_address, sizeof(SlSockAddrIn_t)) >= 0 &&
      sl_Send(socket_handle, request.c_str(), request.length(), 0) >= 0 &&
      sl_Recv(socket_handle, receive_msg_buffer, sizeof(receive_msg_buffer), 0) >= 0) {
    Serial.println(receive_msg_buffer);
    success = true;
  }

  sl_Close(socket_handle);
  return success;
}

首先,我们定义要发送的请求消息。请注意,末尾的双换行符至关重要;它表示标头在此处结束。否则,我们将收到超时,因为服务器正在等待更多内容。然后我们检索主机的 IP 地址以进行连接。然后我们将连接套接字设置为 TCP/IP,端口为 80(标准 HTTP 连接)。接下来的步骤如下

  • 设置套接字选项;在这种情况下,我们将超时设置为 45 秒,
  • 使用先前定义的选项连接到套接字
  • 发送内容:我们传输请求,仅由标头组成,最后
  • 接收答案。

默认情况下,响应将作为单个字符串可用——标头和内容正文之间没有区别。我们需要进行解析。

一旦我们编译并运行此代码,我们应该会看到类似以下内容

HTTP/1.1 200 OK
Server: nginx
Date: Sat, 18 Jun 2016 07:24:31 GMT
Content-Type: application/octet-stream
Content-Length: 4
Connection: keep-alive
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

L8=r

如果我们什么都没收到,我们应该进行一些调试来找出问题根源。最后的字符是我们感兴趣的 4 个随机字节的字符串表示。请注意,随机二进制字节不一定对应于可打印的 ASCII 符号。

调试 LaunchPad

在嵌入式系统上调试软件非常困难。我们所喜爱的绝大多数工具和技术都不可用。基本上,我们回到了编程的石器时代。我们已经看到 Serial 类是一个有用的实用程序,可以让我们了解实际发生的情况。

CC3200 LaunchPad 具有用于开发和调试的 JTAG(4 线)和 SWD(2 线)接口。在本教程中,我们不会深入到硬件级别。相反,我们将使用通过 USB 提供的 FTDI 连接器提供的串行接口。我们已经看到将消息写入串行接口是一个选项,但一个更好的选择是安装 Texas Instruments 的 Code Composer Studio 并使用断点。这恢复了日常编程中最有用和最有效的调试方法之一。

下载并安装 Code Composer Studio 后,我们可以将其设置为以配置的带宽连接到正确的 COM 端口。配置 CCS 指向 Energia 安装路径非常重要。否则,可能找不到必需的库和文件。最后,我们可以直接从 CCS 打开 Energia 项目。

CCS 的另一个很酷的选项是能够直接查看当前使用的寄存器和内存。如下图所示。

CC3200 Debugging
CCS 检查 CC3200 寄存器

通过这种方式,我们可以加快查找代码中可能错误的进程。假设 HTTP 请求现在运行顺利,我们可以回到解决我们最初的问题:从 CC3200 发出安全的 HTTP 请求。

读取加速度计

该板附带一些传感器。有一个温度传感器(称为tmp006),地址为0x41,还有一个加速度计(bma222),地址为0x18。在本例中,我们将使用加速度计,因为它用于演示目的要容易得多(操纵温度的延迟和准确性很难管理)。

警告 由于地址寄存器存在重叠,我们不能将黄色和绿色 LED 与加速度计一起使用(请记住,几乎所有引脚都经过复用)。因此,从现在开始,我们将只使用红色 LED。

为了读取加速度计传感器,我们包含头文件 Wire.h。此外,我们应该将编写的代码放在一个新文件中。我们将称之为 accelerometer.cpp,其头文件为 accelerometer.h。最初,我们的代码如下

#include <Wire.h>
#include "accelerometer.h"

void setup() {
  Serial.begin(9600);
  Serial.println("Entering setup!");
  Wire.begin();
  pinMode(RED_LED, OUTPUT);
}

void loop() {
  Serial.println("Next loop iteration!");
  AccData acc = readAccelerometer();
  Serial.print(acc.x);
  Serial.print(", ");
  Serial.print(acc.y);
  Serial.print(", ");
  Serial.println(acc.z);
  digitalWrite(RED_LED, HIGH);
  delay(1000);
}

readAccelerometer 函数在我们的新头文件中声明。在 setup 函数中,我们需要初始化 Wire 库。在每次迭代中,我们读取加速度计并将值打印到屏幕上。在 Energia 中运行此结果如下

CC3200 Accelerometer Debugger Output
调试器中的 CC3200 加速度计输出

AccData 结构定义在我们的头文件中。这里的代码如下

#pragma once
#include <stdint.h>

struct AccData {
  int8_t x;
  int8_t y;
  int8_t z;
};

AccData readAccelerometer();

非常直接。源文件更有趣。

#include "accelerometer.h"

// ...

int8_t readSingleAxis(uint8_t axis);

AccData readAccelerometer() {
  AccData data;
  data.x = readSingleAxis(0x03);
  data.y = readSingleAxis(0x05);
  data.z = readSingleAxis(0x07);
  return data;
}

我们读取三维加速度向量分量的寄存器,并返回完整结果。现在的问题是 readSingleAxis 用于读取单个分量的定义是什么。其余部分显示如下。

#include <Energia.h>
#include <Wire.h>

void initializeI2C(uint8_t base_address, uint8_t register_address) {
  Wire.beginTransmission(base_address);
  Wire.write(register_address);
  Wire.endTransmission();
}

uint8_t readI2C(uint8_t base_address, uint8_t register_address) {
  initializeI2C(base_address, register_address);
  Wire.requestFrom(base_address, 1);

  while (Wire.available() < 1);

  return Wire.read();
}

int8_t readSingleAxis(uint8_t axis) {
  return readI2C(0x18, axis);
}

每次读取操作都会启动到给定地址设备的 I2C 连接。我们记得加速度计的地址是0x18。然后我们只需遵循 I2C 通信协议,该协议在我们借助 Wire 库实现的,该库抽象了底层的内容。

编写完这部分后,我们可以调整 loop 函数中的代码,以便在板“坠落”的情况下显示红色灯。从我们学校的基础物理课中,我们记得自由落体体基本上是无力的,即 Z 方向的加速度将为零(与站在地表上的物体为 1g 的单位相比)。我们假设加速度计的 Z 轴实际上指向“向上”。

由于默认情况下我们在 Z 方向上测量的值约为 65,我们可以将其标准化到此值。我们应该显示低于 0.4 g 的红色灯,即当值降至 26 以下时。我们修改后的代码如下所示

#include <Wire.h>
#include "accelerometer.h"

void setup() {
  Serial.begin(9600);
  Wire.begin();
  pinMode(RED_LED, OUTPUT);
}

void loop() {
  AccData acc = readAccelerometer();
  Serial.println(acc.z);

  if (acc.z > 26) {
    digitalWrite(RED_LED, LOW);
  } else {
    digitalWrite(RED_LED, HIGH);
  }
}

潜在地,我们可能希望警告保持激活一小段时间,例如一秒钟。在这种情况下,我们可以修改代码如下

if (acc.z > 26) {
  digitalWrite(RED_LED, LOW);
} else {
  digitalWrite(RED_LED, HIGH);
  delay(1000);
}

现在,一旦触发,红色 LED 将点亮至少 1 秒钟。

读取和发送温度

我们可以使用一个简单的 Node.js express 服务器(例如,监听端口 3000)来接收和存储数据。代码可以很简单,如下所示。

const express = require('express');
const app = express();
app.use(function (req, res, next) {
  req.rawBody = '';
  req.setEncoding('utf8');

  req.on('data', function (chunk) {
    req.rawBody += chunk;
  });

  req.on('end', function () {
    next();
  });
});
app.post('/temperature', function (req, res) {
  console.log(req.rawBody);
  // Do something with the raw value!
  res.send('');
});
app.listen(3000);

有了我们之前描述的助手,我们就可以为 CC3200 编写极简且功能强大的代码。

以下代码连接到本地 WiFi,唤醒温度传感器,并对传感器进行连续读取,间隔 2 秒(仅为说明目的)。

Adafruit_TMP006 tmp006(0x41);

void setup() {
  Serial.begin(9600);
  connectWifi(WLAN_PUB_SSID, WLAN_KEY_CODE);
  tmp006.begin();
  tmp006.wake();
  setCurrentTime();
}

void loop() {
  char receive_msg_buffer[1024];
  char send_msg_buffer[128];

  while (true) {
    delay(2000);
    float value = tmp006.readObjTempC();
    sprintf(send_msg_buffer, "%lf", value);
    Serial.println(send_msg_buffer);
    httpPostRequest(TMP_SNSR_HOST, TMP_SNSR_PORT, TMP_SNSR_PATH, send_msg_buffer, receive_msg_buffer);
  }
}

最后,如果我们遵循所有这些步骤,温度将被成功读取和传输。让我们使用调试器进行检查。

CC3200 Send Temperature
监控温度读数和传输

多个传感器标签

如果单个 CC3200 不够,我们还可以包含通过低功耗蓝牙连接的多个温度传感器。这可能比使用 CC3200 更简单,CC3200 涉及低级硬件细节来读取传感器数据并在一个最小的操作系统上执行 HTTP 请求。

SensorTag Raspberry Pi Overview
使用 TI SensorTag 和 Raspberry Pi 3

接下来,我们将解释如何使用传感器标签通过 BTLE 获取温度读数。

要求

为了将此方式完全包含在我们的设置中,我们需要以下附加项

  • Raspberry Pi 上的蓝牙已激活
  • 至少一个 TI SensorTag

我们可以在 Raspberry Pi 3 上使用已安装的编辑器,例如 nano、ed 或 vim。本教程在 Raspberry Pi 3 上使用*Raspbian Jessie* 映像。此映像应附带蓝牙驱动程序,否则请尝试执行以下命令

sudo apt-get install pi-bluetooth

我们首先设置硬件环境。软件应该都已预配置好,特别是如果我们选择使用其中一个可用映像。

从命令行操作蓝牙

我们使用我们喜欢的 SSH 客户端(例如,Windows 上的 Putty)连接到 Raspberry Pi。在 shell 中,我们现在可以使用以下说明与 TI SensorTag 进行交互

  1. 运行标准的蓝牙程序(应随操作系统一起提供),键入 bluetoothctl
  2. 如果尚未打开,请键入 power on 来打开蓝牙。同样,可以使用 power off 来关闭电源。
  3. 使用 devices 命令列出配对的设备。
  4. 使用 scan on 命令进入设备发现模式。一段时间后,传感器标签应该会出现(假设 MAC 地址为 34:B1:F7:D4:F2:CF)。
  5. 键入 pair 34:B1:F7:D4:F2:CF 来创建 Pi 和传感器标签之间的配对。
  6. 现在我们可以使用 scan off 命令停止发现设备。
  7. 键入 quit 退出程序。

这样我们就发现了并配对了我们的设备。现在我们可以使用 gatttool 进行操作。

  1. 通过键入 gatttool -b 34:B1:F7:D4:F2:CF --interactive 运行程序。我们进入交互式会话。
  2. 我们发出的第一个命令是 connect。我们应该看到“Connection successful”消息。
  3. 现在我们可以尝试从传感器标签读取:char-read-hnd 0x25 使用句柄 0x25 从温度计读取数据。我们应该看到一些零。
  4. 要读取一些值,我们需要打开温度计。我们发出 char-write-cmd 0x29 01 命令来打开 0x29 处的温度传感器。
  5. 再次发出命令 char-read-hnd 0x25 现在应该会产生一个非零值。
  6. 键入 quit 退出程序。

句柄

传感器标签附带了许多不同的传感器。这包括

  • 非接触式红外温度传感器(德州仪器 TMP006)
  • 湿度传感器(Sensirion SHT21)
  • 陀螺仪(Invensense IMU-3000)
  • 加速度计(Kionix KXTJ9)
  • 磁力计(Freescale MAG3110)
  • 气压传感器(Epcos T5400)
  • 片上温度传感器(内置于 CC2541)
  • 电池/电压传感器(内置于 CC2541)

打开或关闭不同的传感器会影响功耗。下图说明了不同的功耗要求。

SensorTag Power
TI SensorTag 组件的功耗分布

下表简要概述了前面提到的一些传感器。

Sensor 读取 长度 配置 Data
红外温度 0x25 4 字节 0x29 0x26
加速度计 0x2d 3 字节 0x31 0x2e
湿度 0x38 4 字节 0x3c 0x39
磁力计 0x40 6 字节 0x44 0x41
气压 0x4b 4 字节 0x4f 0x4c
陀螺仪 0x57 6 字节 0x5b 0x58

气压传感器还需要额外的校准。校准必须在第一次测量之前完成。需要以下步骤

  1. 我们发出命令 char-write-cmd 0x4f 02。这将执行校准。
  2. 现在通过 char-read-hnd 0x52 读取设备,得到原始值。

总体而言,下图显示了传感器组件在传感器标签板上的漂亮映射。

SensorTag Hardware
传感器标签硬件一览

基本上就是这样。我们现在可以编写一个简单的 bash 脚本,它只是隔一段时间查询一次传感器并将结果写入文本文件。这与软件模拟中介绍的文本文件结构相同。

例如,以下脚本配对 devices 文件中给出的所有设备。

#!/bin/bash

bluetoothctl <<< "power on"
sleep 1s
bluetoothctl <<< "scan on"
sleep 5s

while IFS='' read -r line || [[ -n "$line" ]]; do
  bluetoothctl <<< "pair ${line}"
done < devices

sleep 4s

while IFS='' read -r line || [[ -n "$line" ]]; do
  bluetoothctl <<< "disconnect ${line}"
done < devices

我们可以使用相同的文件来读取这些设备的温度传感器值。以下 bash 脚本执行此操作。

#!/bin/bash

while IFS='' read -r line || [[ -n "$line" ]]; do
  gatttool -b ${line} --char-write -a 0x29 -n 01
  gatttool -b ${line} --char-read -a 0x25
done < devices

从这一点开始,很容易继续轮询设备(例如,每五分钟一次)以获取有关温度的一些信息。

附加:语音转聊天机器人

一旦我们建立了通用的文本层,我们就可以自由地做任何事情。最有趣的可能之一是在我们的设计之上放置一个文本转语音层。一些客户端(例如,大多数移动平台上的 Skype)免费集成了一个这样的层(通常,这是底层操作系统输入文本框/软键盘的一部分)。然而,出于某些原因,我们可能希望提供一种提供语音识别作为输入的特殊应用程序。

使用 Direct Line

我们需要回答的第一个问题是如何将这样的客户端集成到我们的聊天机器人系统中。答案很简单:通过 Direct Line。除了集成 Skype、Telegram 或 Facebook 等通道外,我们还可以集成一个完全未知的通道。此通道可以针对已知的 API(和文档化的 API)工作,但是,在最简单的情况下,我们只需从 NuGet 获取 Microsoft.Bot.Connector.DirectLine 包。

该库允许我们编写以下代码。这是让您自己的聊天客户端启动和运行所需的一切!

sealed class MessageChannel : IDisposable
{
    private static readonly String BingSecret = ConfigurationManager.AppSettings["BingSecret"];
    private static readonly String EndPoint = ConfigurationManager.AppSettings["SmartBotEndpoint"];
    private static readonly Uri DirectLine = new Uri("https://directline.botframework.com");

    private readonly Conversation _conversation;
    private readonly DirectLineClient _client;

    public event EventHandler<MessageEvent> Received;

    public MessageChannel()
    {
        var credentials = new DirectLineClientCredentials(BingSecret, EndPoint);
        _client = new DirectLineClient(DirectLine, credentials);
        _conversation = _client.Conversations.NewConversation();
    }

    public Task SendAsync(String content)
    {
        var message = new Message(text: content);
        return _client.Conversations.PostMessageAsync(_conversation.ConversationId, message);
    }

    public async Task ReceiveAsync(CancellationToken cancellationToken)
    {
        var watermark = default(String);

        while (!cancellationToken.IsCancellationRequested)
        {
            var messages = await _client.Conversations.GetMessagesAsync(_conversation.ConversationId, watermark, cancellationToken);

            foreach (var message in messages.Messages)
            {
                Received?.Invoke(this, new MessageEvent(message.Text, message.Created, message.FromProperty));
            }

            watermark = messages.Watermark;
        }
    }

    public void Dispose()
    {
        _client.Dispose();
    }
}

发送消息的方法是将单个消息发布到对话中。可以通过 GetMessagesAsync 方法获取对话中的消息(包括我们发送的消息)。在这里,我们应该提供一个水印,以便只获取*自上次检索以来*的消息(带有水印)。我们编写的方法会永久接收消息,基本上实现一种长轮询机制。我们收到的消息批次以事件的形式传递。

Bing Speech-To-Text

在 C# 应用程序中包含语音识别的一个非常简单的方法是 Bing Speech-To-Text API。同样,我们可以使用 NuGet 上免费提供的库。我们使用 Microsoft.ProjectOxford.SpeechRecognition-x64 包创建一个 VoiceChannel 类,以允许麦克风输入。

这个库的优点在于它的简单性。我们只需要使用 SpeechRecognitionServiceFactory 工厂创建一个新客户端。在我们的例子中,我们希望 Bing Speech-To-Text 服务也利用我们定义的模型(目前 resides in LUIS)。因此,我们选择了 CreateMicrophoneClientWithIntent 方法。除了显而易见的 Bing 订阅外,我们还需要提供我们的 LUIS 应用程序和订阅 ID。其余工作由内部完成。

该库甚至接管了查找和控制麦克风的任务。录制等所有内容都直接集成。一些事件使得该库平滑易用。

以下代码说明了基本用法。

sealed class VoiceChannel : IDisposable
{
    private static String SpeechLocale = ConfigurationManager.AppSettings["SpeechLocale"];
    private static String LuisApplicationId = ConfigurationManager.AppSettings["LuisApplicationId"];
    private static String LuisSubscriptionId = ConfigurationManager.AppSettings["LuisSubscriptionId"];
    private static String BingPrimaryKey = ConfigurationManager.AppSettings["BingPrimaryKey"];
    private static String BingSecondaryKey = ConfigurationManager.AppSettings["BingSecondaryKey"];

    private readonly MicrophoneRecognitionClientWithIntent _mic;
    private Boolean _recording;

    public event EventHandler<IntentEvent> ReceivedIntent
    {
        add { _mic.OnIntent += (sender, ev) => value.Invoke(sender, new IntentEvent(ev.Payload)); }
        remove { _mic.OnIntent -= (sender, ev) => value.Invoke(sender, new IntentEvent(ev.Payload)); }
    }

    public VoiceChannel()
    {
        _recording = false;
        _mic = SpeechRecognitionServiceFactory.CreateMicrophoneClientWithIntent(
            SpeechLocale,
            BingPrimaryKey,
            BingSecondaryKey,
            LuisApplicationId,
            LuisSubscriptionId);
        _mic.OnResponseReceived += OnResponseReceived;
        _mic.OnMicrophoneStatus += OnMicrophoneStatus;
    }

    public Boolean IsRecording
    {
        get { return _recording; }
    }

    public void ToggleRecording()
    {
        if (_recording)
        {
            _mic.EndMicAndRecognition();
        }
        else
        {
            _mic.StartMicAndRecognition();
        }
    }

    private void OnMicrophoneStatus(Object sender, MicrophoneEventArgs e)
    {
        _recording = e.Recording;
    }

    private void OnResponseReceived(Object sender, SpeechResponseEventArgs e)
    {
        foreach (var phrase in e.PhraseResponse?.Results ?? Enumerable.Empty<RecognizedPhrase>())
        {
            Debug.WriteLine(phrase.DisplayText);
        }
    }

    public void Dispose()
    {
        _mic.Dispose();
    }
}

有了语音识别可用,我们可以轻松地将所有内容连接起来。最终,UI 可以很简单,只有一个按钮来启动和结束语音识别。消息对话框显示所有执行的问题和答案。

潜在地,我们也可能想要一些语音输出。可以使用 System.Speech 库(应随 Windows 8+ 和 .NET 4.5 一起部署)轻松集成。

合成文本转语音

使用可用的语音合成器是一种廉价可靠的提供基本音频输出的方法。这可以在许多场景中使用,主要是为了提高可访问性。可访问性这个主题当然不是我们场景的主要用例。我们只是想在这里展示一些酷的东西并说明可能性。

以下代码实现了合成给定消息的可能性。当然,该类或多或少是一个无用的包装器,但是,请注意 SpeakAsync 不返回 Task。为了知道消息何时完成,我们需要检查返回的提示的 IsCompleted 属性。这个逻辑(检查消息、排队等)本可以在此处实现,但为了简洁起见,实现保持简单和最小。

sealed class SpeechChannel : IDisposable
{
    private readonly SpeechSynthesizer _synthesizer;

    public SpeechChannel()
    {
        _synthesizer = new SpeechSynthesizer();
    }

    public void Dispose()
    {
        _synthesizer.Dispose();
    }

    public void Say(String message)
    {
        _synthesizer.SpeakAsync(message);
    }
}

最终结果的截图如下所示。这是一个轻量级的 WPF 应用程序,它基本上只包含一个项控件和一个按钮。就这样。消息是连续接收的。

Bing Speech API Demo WPF
语音转文本 WPF 演示应用程序

配置设置

为了使用语音客户端,我们仍然需要输入一些密钥和地址。所有配置都在提供的源代码的 app.config 中执行。

以下代码片段说明了需要填写的字段。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <appSettings>
    <add key="LuisApplicationId" value="/* Paste Your LUIS Application ID here */" />
    <add key="LuisSubscriptionId" value="/* Paste Your LUIS Subscription ID here */" />
    <add key="SpeechLocale" value="en-US" />
    <add key="BingPrimaryKey" value="/* Paste Your Bing Speech-To-Text Primary Key ID here */" />
    <add key="BingSecondaryKey" value="/* Paste Your Bing Speech-To-Text Secondary Key ID here */" />
    <add key="DirectLineSecret" value="/* Paste Your Direct Line Secret here */" />
    <add key="SmartBotEndpoint" value="/* Paste Your Chat Bot API Endpoint here */" />
  </appSettings>
  <!-- ... --> 
</configuration>

除了 LUIS 订阅为 Bing Speech-To-Text 提供更多信息和解析模型外,我们还需要 Bing Speech-To-Text 订阅。目前它是预览版。在网页上,我们可以访问主密钥和辅助密钥。同样,我们需要 Direct Line 连接的密钥。这确保了我们的客户端有资格与我们的聊天机器人适配器通信。

兴趣点

该系统非常灵活,可以轻松用于集成其他家庭自动化设施。我个人还增加了对一些服务器基础设施和多媒体产品的监控。我依赖某个平台的应用程序来控制它的日子已经一去不复返了。我只需要有一个或另一个信使来了解我的聊天机器人。然后我就可以简单地写下诸如“停止播放当前电影”或“将音量提高 5%”之类的消息。有了本提案中的系统,添加新意图就非常简单直接。

目前我有 3 个通道永久连接到我的聊天机器人。第四个通道是 Web 聊天,为了完整起见,在以下截图中显示。

SmartBot Connected Clients
连接到系统的各种客户端

这样,就可以通过多个通道使用聊天机器人,例如 Skype、Slack、GroupMe。当然,这三者是不同的,但是,作为一种扩展,我们当然可以将特定于对话的逻辑存储在本地聊天机器人客户端上。这样,我们可以轻松打破对话界限,例如在一个客户端中回答一个问题,然后从另一个客户端获取信息。然而,目前,工作量和潜在的用户混淆似乎都不能支持这种可能性。我将在未来重新评估这一点。

一个简短的 GIF 视频说明了通过 Skype 与聊天机器人的基本交互。

SmartBot Skype Demo
Skype 演示会话与聊天机器人

参考文献

这是一系列参考资料,详细介绍了文章中提到的不同主题。

历史

  • v1.0.0 | 初始发布 | 2016 年 8 月 7 日
  • v1.0.1 | 添加了关于软件模拟的几行 | 2016 年 8 月 7 日
  • v1.1.0 | 添加了 CC3200 的代码 | 2016 年 8 月 8 日
  • v1.2.0 | 添加了语音转文本示例 | 2016 年 8 月 10 日
  • v1.2.1 | 包括 Skype 演示屏幕 | 2016 年 8 月 12 日
© . All rights reserved.