JavaScript ESP32 和 ESP8266 物联网开发教程(二)
三、网络
物联网设备种类繁多,从恒温器到门锁,从智能手表到智能灯泡,从洗衣机到安全摄像头,人们很容易忘记它们都有一个共同点:网络。物联网设备与普通日常设备的区别在于它与网络的连接。本章讲述的都是这种连接,从不同的网络连接方式开始。
一旦您的设备连接到网络,它可以通过多种不同的方式进行通信。本章向您展示了如何使用与您的计算机和电话上的 web 浏览器相同的 HTTP 网络协议进行通信。它还展示了如何使用 WebSocket 协议进行交互式双向通信,以及如何使用 MQTT 协议进行发布和订阅。
保护通信对于许多产品来说是必不可少的,因此您还将了解如何结合使用 TLS(传输层安全性)和 HTTP、WebSocket 和 MQTT 等协议来建立安全连接。
本章以两个高级主题结束。首先是如何将你的设备变成 Wi-Fi 基站,这是许多商业物联网产品为了便于配置而使用的技术。您可以将电脑、电话和其他设备连接到此专用 Wi-Fi 基站,而无需安装任何特殊软件。第二个高级主题是如何在网络 API 中使用 JavaScript promises。
关于网络
这本书重点介绍了使用 Wi-Fi 连接到网络的硬件。你的 Wi-Fi 接入点*、也称为基站或路由器、*将你的 Wi-Fi 网络连接到互联网。接入点还创建了一个本地网络,允许连接到它的设备相互通信。HTTP、MQTT 和 WebSocket 协议用于与互联网上的服务器通信,但它们也可用于本地 Wi-Fi 网络上的设备之间的通信。设备之间的直接通信速度更快,也更私密,因为您的数据永远不会离开您的 Wi-Fi 网络。它消除了云服务的成本。使用 mDNS 网络协议可以使本地网络上的设备轻松地直接相互通信。
本章所有的组网例子都是非阻塞(或异步)。这意味着,例如,当您使用 HTTP 协议从网络请求数据时,您的应用程序会在发出请求的同时继续运行。这与您在 web 上使用 JavaScript 时联网的工作方式相同,但与嵌入式环境中的许多联网实现不同。由于种种原因,很多嵌入式开发环境用阻塞组网代替;这使得设备在网络操作期间对用户输入没有响应,除非还使用了更复杂和更占用内存的技术,例如线程。
可修改的 SDK 中实现网络功能的类使用回调函数来提供状态和传递网络数据。回调实现起来很简单,即使在处理能力和内存相对较小的硬件上,它们也能有效地运行。在 web 上,开发人员很早就开始使用回调来进行网络操作。最近,JavaScript 的一个名为的特性承诺已经成为某些情况下回调的流行替代。因为承诺需要更多的资源,所以在这里很少使用。为可修改的 SDK 提供动力的 XS 引擎支持承诺。本章中介绍的网络功能可能适用于使用承诺;本章末尾关于承诺的部分有一个例子。
连接到 Wi-Fi
你已经知道如何连接你的电脑和电话(甚至你的电视!)连接到互联网,这种体验将有助于您编写连接设备的代码。你还需要学习一些新东西,因为物联网设备并不总是有屏幕,没有屏幕,用户就不能简单地点击 Wi-Fi 网络的名称来连接。
本节描述了连接到 Wi-Fi 的三种不同方式:
-
从命令行
-
用简单的代码连接到一个已知的无线接入点
-
通过扫描开放的 Wi-Fi 接入点
每一个都适用于不同的情况;你会为你的项目选择最好的一个。使用命令行对于开发来说是非常好的,但是当您从实验转向构建复杂的原型和真实产品时,需要另外两种方法。
Note
本节使用了一种不同于你在第一章中学到的安装模式:不是用mcconfig安装主机,然后用mcrun安装示例,而是用mcconfig安装示例。
从命令行连接
在第一章中,你学会了使用mcconfig命令行工具来构建和安装主机。mcconfig命令可以定义变量。如以下命令所示,您可以通过用 Wi-Fi 接入点名称的值定义变量ssid来连接到 Wi-Fi 接入点。 SSID 代表服务集标识符,是 Wi-Fi 基站提供的 Wi-Fi 网络的人类可读名称的技术术语。
> mcconfig -d -m -p esp ssid="my wi-fi"
以这种方式定义ssid会导致一个配置变量被添加到您的应用程序中,设备的基本网络固件会使用该变量在设备启动时自动连接到 Wi-Fi。建立 Wi-Fi 连接后,您的应用程序就可以运行了。这很方便,因为这意味着您的应用程序可以假设网络总是可用的。
如果您的 Wi-Fi 接入点需要密码,请在命令行中将其作为password变量的值:
> mcconfig -d -m -p esp ssid="my wi-fi" password="secret"
在 Wi-Fi 连接过程中,调试控制台中会显示诊断跟踪消息。观察消息有助于诊断连接故障。下面是一个成功连接的例子:
Wi-Fi connected to "Moddable"
IP address 10.0.1.79
如果 Wi-Fi 存取点拒绝 Wi-Fi 密码,会显示以下信息:
Wi-Fi password rejected
所有其他不成功的连接尝试都会显示以下消息:
Wi-Fi disconnected
在您的设备上安装$EXAMPLES/ch3-network/wifi-command-line示例来测试这种连接方法。
用代码连接
使用命令行选项来定义您的 Wi-Fi 凭证便于开发,但是对于您与其他人共享的项目,您通常会想要将 Wi-Fi 凭证储存在偏好设置中。本节介绍连接到应用程序中定义的 Wi-Fi 接入点的代码。(管理首选项在第五章中描述。)
wifi模块包含用于管理 Wi-Fi 网络连接的 JavaScript 类。要在您的代码中使用wifi模块,首先从其中导入WiFi类:
import WiFi from "wifi";
使用WiFi类的静态connect方法连接到 Wi-Fi 网络。在$EXAMPLES/ch3-network/wifi-code的例子中,SSID 和密码作为字典中的属性传递给构造函数(清单 3-1 )。
WiFi.connect({
ssid: "my wi-fi",
password: "secret"
}
);
Listing 3-1.
这个调用开始了建立连接的过程。调用是异步的,这意味着实际的连接工作在后台进行;建立连接时,应用程序会继续运行。这就像在你的手机上一样,当 Wi-Fi 连接建立时,你可以继续使用应用程序。在物联网设备中,您通常希望知道网络连接何时可用,以便您知道您的应用程序何时可以连接到其他设备和互联网。
为了监控连接状态,创建一个WiFi类的实例,并提供一个监控回调函数(清单 3-2 ),当连接状态改变时调用该函数。
let wifiMonitor = new WiFi({
ssid: "my wi-fi",
password: "secret"
},
function(msg) {
switch (msg) {
case WiFi.gotIP:
trace("network ready\n");
break;
case WiFi.connected:
trace("connected\n");
break;
case WiFi.disconnected:
trace("connection lost\n");
break;
}
}
);
Listing 3-2.
根据连接状态,使用以下三种消息之一调用回调函数:
-
connected–您的设备已连接到 Wi-Fi 接入点。然而,它还不能使用,因为它还没有收到它的 IP 地址。当您看到此消息时,您知道 SSID 和密码是有效的。 -
gotIP–您的设备已收到其 IP 地址,现在可以与本地网络和互联网上的其他设备进行通信。 -
disconnected–您的设备失去了网络连接。在某些设备上,您会在收到connect消息之前收到此消息。
一些项目让WiFi对象一直保持活动状态,以监控网络断开。如果您不需要监控掉线的网络连接,您应该关闭WiFi对象来释放它正在使用的内存。
wifiMonitor.close();
关闭WiFi对象不会断开与 Wi-Fi 网络的连接。这仅仅意味着您的回调函数将不再被调用,并且不会收到关于回调状态的通知。
要断开与 Wi-Fi 网络的连接,调用WiFi类的静态disconnect方法:
WiFi.disconnect();
要测试此连接方法,请执行以下步骤:
-
在文本编辑器中打开
$EXAMPLES/ch3-network/wifi-code/main.js。 -
更改第 4 行和第 5 行,使
ssid和password与您的网络凭证相匹配。 -
使用
mcconfig从命令行在您的设备上安装$EXAMPLES/ch3-network/wifi-code示例。
如果连接成功,您将看到跟踪到调试控制台的以下消息:
connected
network ready
如果连接不成功,您将会看到connection lost重复显示。
连接到任何开放的接入点
有时,您会希望您的物联网设备连接到任何可用的开放 Wi-Fi 接入点(例如,不需要密码的接入点)。从安全角度来看,连接到未知网络并不是一个好主意,但在某些情况下,便利性更重要。
要连接到开放的接入点,第一步是找到一个。WiFi类提供了静态的scan方法来寻找访问点。清单 3-3 中的代码执行一次访问点扫描,将结果记录到调试控制台。它从accessPoint的rssi属性中获取信号强度。 RSSI 代表接收信号强度指示,是对从 Wi-Fi 接入点接收到的信号强度的测量。它的值是负数,信号越强,RSSI 值越接近 0。
WiFi.scan({}, accessPoint => {
if (!accessPoint) {
trace("scan complete\n");
return;
}
let name = accessPoint.ssid;
let open = "none" === accessPoint.authentication;
let signal = accessPoint.rssi;
trace(`${name}: open=${open}, signal=${signal}\n`);
});
Listing 3-3.
下面是这段代码的输出示例:
ESP_E5C7AF: open=true, signal=-62
Large Conf.: open=false, signal=-85
Expo 2.4: open=false, signal=-74
PAB: open=true, signal=-77
Kanpai: open=false, signal=-66
Moddable: open=false, signal=-70
scan complete
扫描持续时间通常少于 5 秒,因设备而有所不同。在扫描过程中,该示例跟踪接入点的名称、它是否打开以及它的信号强度。当扫描完成时,调用scan回调函数,将accessPoint参数设置为undefined,并跟踪消息scan complete。
如果您所在的位置有许多接入点,单次扫描可能无法发现每个可用的接入点。要构建一个完整的列表,您的应用程序可以合并几次扫描的结果。有关示例,请参见可修改的 SDK 中的wifiscancontinuous。
选择 Wi-Fi 接入点进行连接的用户通常会选择强度最大的接入点。$EXAMPLES/ch3-network/wifi-open-ap示例使用清单 3-4 中的代码执行相同的选择过程。
let best;
WiFi.scan({}, accessPoint => {
if (!accessPoint) {
if (!best) {
trace("no open access points found\n");
return;
}
trace(`connecting to ${best.ssid}\n`);
WiFi.connect({ssid: best.ssid});
return;
}
if ("none" !== accessPoint.authentication)
return; // not open
if (!best) {
best = accessPoint; // first open access point found
return;
}
if (best.rssi < accessPoint.rssi)
best = accessPoint; // new best
});
Listing 3-4.
这段代码使用变量best来跟踪扫描期间信号强度最强的开放接入点。扫描完成后,代码连接到该接入点。
要测试这种方法,请在您的设备上安装wifi-open-ap示例。
安装网络主机
主机在$EXAMPLES/ch3-network/host目录中。从命令行导航到这个目录,用mcconfig安装它。
安装示例
本章中的示例仅在设备连接到 Wi-Fi 接入点时才能正常工作。在本章的前面,您学习了如何通过在mcconfig命令中定义变量来指定接入点的 SSID 和密码。在示例运行之前,您可以在mcrun命令中使用这些相同的变量将您的设备连接到 Wi-Fi。
> mcrun -d -m -p esp ssid="my wi-fi"
> mcrun -d -m -p esp ssid="my wi-fi" password="secret"
获取网络信息
使用网络时,您可能需要有关网络接口或网络连接的信息,以便进行调试或实现功能。该信息可从net模块获得。
import Net from "net";
使用静态的get方法从Net对象中检索信息。此示例检索设备所连接的 Wi-Fi 接入点的名称:
let ssid = Net.get("SSID");
以下是您可以检索到的一些其他信息:
-
IP–网络连接的 IP 地址;例如,10.0.1.4 -
MAC–网络接口的 MAC 地址;例如,A4:D1:8C:DB:C0:20 -
SSID–无线接入点的名称 -
BSSID–Wi-Fi 接入点的 MAC 地址;例如,18:64:72:47:d4:32 -
RSSI–Wi-Fi 信号强度
发出 HTTP 请求
互联网上最常用的协议是 HTTP,它的流行有许多很好的理由:它相对简单,得到广泛支持,适用于少量和大量数据,已被证明非常灵活,并且可以在多种设备上得到支持,包括许多物联网产品中相对便宜的设备。本节展示了如何向 HTTP 服务器发出不同种类的 HTTP 请求。(下一节将展示如何保护这些连接。)
基本原则
http模块包含对发出 HTTP 请求和创建 HTTP 服务器的支持。要发出 HTTP 请求,首先从模块中导入Request类:
import {Request} from "http";
Request类使用字典来配置请求。字典中只有两个必需的属性:
-
一个
host属性或一个address属性来定义要连接的服务器,其中host通过名称指定服务器(例如www.example.com)address通过 IP 地址定义服务器(例如10.0.1.23) -
一个
path属性,用于指定要访问的 HTTP 资源的路径(例如,/index.html或/data/lights.json)
所有其他属性都是可选的;您发出的 HTTP 请求的类型决定了它们是否存在以及它们的值是什么。下面几节将介绍许多可选属性。
除了配置字典之外,每个 HTTP 请求都有一个回调函数,在请求的各个阶段都会调用这个函数。回调接收对应于当前阶段的消息。以下是 HTTP 请求各个阶段的完整列表:
-
requestFragment–回调被要求提供请求体的下一部分。 -
status–已收到 HTTP 响应的状态行。HTTP 状态代码(例如,200、404 或 301)可用。状态代码指示请求是成功还是失败。 -
header–已收到 HTTP 响应报头。对于收到的每个 HTTP 头,都会重复此消息。 -
headersComplete–在收到最终 HTTP 响应头和响应体之间收到此消息。 -
responseFragment–此消息提供了 HTTP 响应的一个片段,可能会被多次接收。 -
responseComplete–此消息在所有 HTTP 响应片段之后接收。 -
error–处理 HTTP 请求时出现故障。
如果这看起来势不可挡,不要担心;许多 HTTP 请求只使用这些消息中的一两条。其中两个消息requestFragment和responseFragment仅用于处理太大而不适合设备内存的 HTTP 数据。接下来的部分展示了如何使用许多可用的消息。
GET
最常见的 HTTP 请求是GET,它检索一段数据。来自$EXAMPLES/ch3-network/http-get示例的清单 3-5 中的代码执行 HTTP GET从 web 服务器www.example.com获取主页。
let request = new Request({
host: "www.example.com",
path: "/",
response: String
});
request.callback = function(msg, value) {
if (Request.responseComplete === msg)
trace(value, "\n");
}
Listing 3-5.
对Request构造函数的调用中的response属性指定了您希望如何返回响应的主体。在这种情况下,您指定它应该作为 JavaScript 字符串返回。当收到响应——整个网页——时,回调函数接收到responseComplete消息。网页存储在value参数中。对trace的调用在调试控制台中显示了源 HTML。
您可以在项目中使用这种方法来检索文本数据。如果想要检索二进制数据,可以通过为response属性传递一个值ArrayBuffer而不是String来实现,如清单 3-6 所示。
let request = new Request({
host: "httpbin.org",
path: "/bytes/1024",
response: ArrayBuffer
});
Listing 3-6.
只要设备上有足够的内存来保存它,一次获取整个 HTTP 响应就能很好地工作。如果没有足够的内存,请求就会失败,并显示一条error消息。下一节将解释如何检索大于可用内存的资源。
流式传输GET
在对 HTTP 请求的响应不适合可用内存的情况下,您可以发出一个流 HTTP GET请求。这只是稍微复杂一点,如清单$EXAMPLES/ch3-network/http-streaming-get示例中的 3-7 所示。
let request = new Request({
host: "www.bing.com",
path: "/"
});
request.callback = function(msg, value, etc) {
if (Request.responseFragment === msg)
trace(this.read(String), "\n");
else if (Request.responseComplete === msg)
trace(`\n\nTransfer complete.\n\n`);
}
Listing 3-7.
注意,在对构造函数的调用中,response属性并不存在。该属性的缺失告诉 HTTP Request类在收到响应体的每个片段时,用responseFragment消息将其传递给回调。在本例中,回调将数据作为字符串读取,以跟踪调试控制台,但它也可以将数据作为ArrayBuffer读取。回调可能会将数据写入文件,而不是跟踪到调试控制台;你将在第五章中学习如何做到这一点。
当您流式传输一个 HTTP 请求时,响应的主体不在带有responseComplete消息的value参数中提供。
Request类支持 HTTP 协议的分块传输编码特性。该特性通常用于提供大量响应。HTTP Request类在调用回调函数之前对块进行解码。因此,回调函数不需要解析块头,从而简化了代码。
GET上
物联网产品通常不会请求网页,除非它们正在抓取页面以提取数据;相反,他们使用 REST APIs,通常用 JSON 来响应。因为 JSON 是 JavaScript 的一个非常小的纯数据子集,所以在 JavaScript 代码中使用它非常方便。清单 3-8 是一个请求休息天气服务的例子。$EXAMPLES/ch3-network/http-get-json中使用的应用 ID 只是一个例子;你应该在 openweathermap.org 注册自己的应用 ID ( APPID)来代替使用。
const APPID = "94de4cda19a2ba07d3fa6450eb80f091";
const zip = "94303";
const country = "us";
let request = new Request({
host: "api.openweathermap.org",
path: `/data/2.5/weather?appid=${APPID}&` +
`zip=${zip},${country}&units=imperial`
response: String
});
request.callback = function(msg, value) {
if (Request.responseComplete === msg) {
value = JSON.parse(value);
trace(`Location: ${value.name}\n`);
trace(`Temperature: ${value.main.temp} F\n`);
trace(`Weather: ${value.weather[0].main}.\n`);
}
}
Listing 3-8.
注意,在传递给Request构造函数的字典中,response被设置为String,就像前面的GET例子一样。响应被请求为String,因为 JSON 是一种文本格式。一旦响应可用,回调就会接收到responseComplete消息,然后使用JSON.parse将接收到的字符串转换成 JavaScript 对象。最后,它从对调试控制台的响应中跟踪三个值。
如果您想知道天气服务返回的所有可用值,您可以阅读它们的文档或者直接在调试控制台中查看响应。要查看调试器,在第一个trace调用时设置一个断点;当在断点处停止时,展开value属性查看值,如图 3-1 所示。
图 3-1
扩展的 JSON 天气响应如xsbug所示
如图所示,服务器返回的 JSON 包含许多 JavaScript 代码不使用的属性,比如clouds和visibility。在某些情况下,设备上有足够的内存来保存整个 JSON 文本,但没有足够的内存来保存通过调用JSON.parse创建的 JavaScript 对象。由于 JavaScript 对象在内存中的存储方式,对象可能比文本使用更多的内存。为了帮助解决这个问题,XS JavaScript 引擎支持调用JSON.parse的第二个可选参数。如果第二个参数是一个数组,那么 JSON 只解析数组中的属性名。这可以显著减少所使用的内存,解析也运行得更快。下面是如何更改前面示例中对JSON.parse的调用,以便只解码该示例使用的属性:
value = JSON.parse(value, ["main", "name", "temp", "weather"]);
创建 HTTP 请求的子类
HTTP Request类是一个低级类,它以很高的效率提供了大量的功能,为广泛的物联网场景提供了必要的功能和灵活性。尽管如此,对于任何给定的情况,代码的功能目的可能会被与 HTTP 协议相关的细节所掩盖。考虑上一节的清单 3-8 中的代码:输入是邮政编码和国家,输出是当前的天气状况,但是其他的都是实现细节。
简化代码的一个好方法是创建一个子类。一个设计良好的子类提供了一个集中的、易于使用的 API,它只接受相关的输入(例如,邮政编码),只提供想要的输出(例如,天气状况)。$EXAMPLES/ch3-network/http-get-subclass示例(清单 3-9 )显示了前一节中天气请求的子类设计。
const APPID = "94de4cda19a2ba07d3fa6450eb80f091";
class WeatherRequest extends Request {
constructor(zip, country) {
super({
host: "api.openweathermap.org",
path: `/data/2.5/weather?appid=${APPID}&` +
`zip=${zip},${country}&units=imperial`,
response: String
});
}
callback(msg, value) {
if (Request.responseComplete === msg) {
value = JSON.parse(value,
["main", "name", "temp", "weather"]);
this.onReceived({
temperature: value.main.temp,
condition: value.weather[0].main}
);
}
}
}
Listing 3-9.
使用这个WeatherRequest子类很容易(清单 3-10 ,因为 HTTP 协议、 openweathermap.org API 和 JSON 解析的所有细节都隐藏在子类的实现中。
let weather = new WeatherRequest(94025, "us");
weather.onReceived = function(result) {
trace(`Temperature is ${result.temperature}\n`);
trace(`Condition is ${result.condition}\n`);
}
Listing 3-10.
设置请求标题
HTTP 协议使用报头向服务器传递关于请求的附加信息。例如,在标准 HTTP 头之一的User-Agent头中包含发出 HTTP 请求的产品的名称和版本是很常见的。您还可以在请求中包含非标准的 HTTP 头,以便将信息传递给特定的云服务。
清单 3-11 展示了如何给 HTTP 请求添加头。它添加了标准的User-Agent标题和自定义的X-Custom标题。标头以数组的形式提供,每个标头的名称后跟其值。
let request = new Request({
host: "api.example.com",
path: "/api/status",
response: String,
headers: [
"User-Agent", "my_iot_device/0.1 example/1.0",
"X-Custom", "my value"
]
});
Listing 3-11.
在数组中而不是在字典或Map对象中指定头有点不寻常。在这里这样做是因为这样更高效,并且减少了物联网设备上所需的资源。
获取响应标头
HTTP 协议使用标头向客户端传达有关响应的附加信息。常见的头标是Content-Type,表示响应的数据类型(如text/plain、application/json或image/png)。响应头通过header消息传递给回调函数。一次传送一个报头,以通过避免将所有接收的报头一次存储在存储器中的需要来减少存储器的使用。当接收到所有的响应头时,用headersComplete消息调用回调。
清单 3-12 检查接收到的所有报头是否有Content-Type报头。如果找到一个,它的值被存储在变量contentType中。在收到所有的头之后,代码检查是否收到了一个Content-Type头(也就是说,contentType不是undefined)并且内容类型是text/plain。
let contentType;
request.callback = function(msg, value, etc) {
if (Request.header === msg) {
if ("content-type" === value)
contentType = etc;
}
else if (Request.headersComplete === msg) {
trace("all headers received\n");
if ((undefined === contentType) ||
!contentType.toLowerCase().startsWith("text/plain"))
this.close();
}
}
Listing 3-12.
根据定义,HTTP 头的名称是不区分大小写的,所以Content-Type、content-type和CONTENT-TYPE都指同一个头。HTTP Request类将头的名称转换成小写,所以回调在头名称比较中总是可以使用小写字母。
POST
到目前为止,所有 HTTP 请求的例子都使用了默认的 HTTP 请求方法GET,并且有一个空的请求体。HTTP Request类支持将请求方法设置为任何值,比如POST,并提供请求体。
$EXAMPLES/ch3-network/http-post示例(清单 3-13 )使用 JSON 请求体对 web 服务器进行POST调用。字典的method属性定义了 HTTP 请求方法,body属性定义了请求体的内容。请求体可以是一个字符串或一个ArrayBuffer。请求被发送到服务器,服务器回显 JSON 响应。回调函数将回显的 JSON 值跟踪到调试控制台。
let request = new Request({
host: "httpbin.org",
path: "/post",
method: "POST",
body: JSON.stringify({string: "test", number: 123}),
response: String
});
request.callback = function(msg, value) {
if (Request.responseComplete === msg) {
value = JSON.parse(value);
trace(`string: ${value.json.string}\n`);
trace(`number: ${value.json.number}\n`);
}
}
Listing 3-13.
此示例将整个请求体存储在内存中。在某些情况下,没有足够的可用内存来存储请求体,例如在上载大文件时。HTTP Request类支持请求体的流式传输;有关这方面的示例,请参见可修改 SDK 中的examples/network/http/httppoststreaming示例。
处理错误
有时 HTTP 请求失败,可能是由于网络故障或请求有问题。在所有情况下,故障都是不可恢复的。因此,您需要决定如何以适合您的物联网产品的方式处理错误,例如向用户报告、立即重试、稍后重试或忽略错误。如果您还没有准备好将错误处理添加到项目中,添加错误诊断跟踪是一个好的开始,因为它有助于您在开发过程中发现失败。
当故障是由于网络错误——网络故障、DNS 故障或服务器故障——时,您的回调将通过error消息调用。以下示例显示了跟踪调试控制台故障的回调:
request.callback = function(msg, value) {
if (Request.error === msg)
trace(`http request failed: ${value}\n`);
}
如果失败是由于请求的问题(格式不正确、路径无效或您没有适当的授权),服务器会以 HTTP 状态代码中的错误作为响应。HTTP Request类在status消息中向回调提供状态代码。对于许多 web 服务来说,从 200 到 299 的状态代码意味着请求成功,而其他的则表示失败。清单 3-14 演示了如何处理 HTTP 状态代码。
request.callback = function(msg, value) {
if (Request.status === msg) {
if ((value < 200) || (value > 299))
trace(`http status error: ${value}\n`);
}
}
Listing 3-14.
保护与 TLS 的连接
安全通信是大多数物联网产品的重要组成部分。它有助于维护产品生成的数据的隐私,并防止数据在从设备转移到服务器时被篡改。在网络上,大多数通信都是使用传输层安全或 TLS 来保护的,它取代了安全套接字层(SSL)。TLS 是一种低级工具,用于保护与许多不同协议的通信。本节解释如何将 TLS 与 HTTP 协议一起使用。同样的方法也适用于 WebSocket 和 MQTT 协议,这将在后面描述。
由于内存、处理能力和存储空间的减少,在嵌入式设备上使用 TLS 比在计算机、服务器或移动设备上更具挑战性。事实上,建立安全的 TLS 连接是许多物联网产品执行的计算要求最高的任务。
将 TLS 与SecureSocket类一起使用
SecureSocket类以一种可用于各种网络协议的方式实现 TLS。要使用SecureSocket,您必须首先导入它:
import SecureSocket from "securesocket";
要发出一个安全的 HTTP 请求(HTTPS),添加一个值为SecureSocket的Socket属性,它告诉 HTTP Request类使用安全套接字而不是默认的标准套接字。清单 3-15 摘自$EXAMPLES/ch3-network/https-get示例,该示例显示了之前 HTTP GET示例(清单 3-5 )中的字典,该字典被修改为发出 HTTPS 请求。
let request = new Request({
host: "www.example.com",
path: "/",
response: String,
Socket: SecureSocket
});
Listing 3-15.
回调与原始示例没有变化。
公共证书
证书是 TLS 提供安全性的一个重要部分:它们使客户端能够验证服务器的身份。证书内置于物联网产品的软件中,就像它们内置于 web 浏览器中一样,但有一点不同:web 浏览器可以存储数百个证书,足以验证互联网上所有公共可用服务器的身份,而物联网产品没有足够的存储空间来保存这么多证书。幸运的是,物联网产品通常只与少数服务器通信,因此您可以只包含您需要的证书。
证书是数据,因此它们存储在应用程序可以访问的资源中,而不是存储在代码中。HTTPS GET示例的清单包括验证www.example.com身份所需的证书(列表 3-16 )。
"resources": {
"*": [
"$(MODULES)/crypt/data/ca107"
]
}
Listing 3-16.
如果您尝试访问一个网站,而证书的资源不可用,TLS 实现会抛出如图 3-2 所示的错误。
图 3-2
xsbug中的 TLS 证书错误消息
该错误显示了缺少的资源的编号,因此您可以修改清单以包含该资源(清单 3-17 )。
"resources": {
"*": [
"$(MODULES)/crypt/data/ca106"
]
}
Listing 3-17.
这是可行的,因为可修改的 SDK 包括大多数公共网站的证书。下一节将描述如何连接到使用私有证书的服务器。
私有证书
私有证书通过确保只有拥有私有证书的物联网产品才能连接到服务器来提供额外的安全性。私有证书通常在扩展名为.der的文件中提供。要在您的项目中使用私有证书,首先将证书放在与您的清单相同的目录中,并修改清单以包含它(清单 3-18 )。请注意,清单不包括文件扩展名.der。
"resources": {
"*": [
"./private_certificate"
]
}
Listing 3-18.
接下来,如清单 3-19 所示,您的应用程序从资源中加载证书,并将其传递给构造器字典的secure属性中的 HTTP 请求。
import Resource from "resource";
let cert = new Resource("private_certificate.der");
let request = new Request({
host: "iot.privateserver.net",
path: "/",
response: String,
Socket: SecureSocket,
secure: {
certificate: cert
}
});
Listing 3-19.
创建 HTTP 服务器
在您的物联网产品中包含 HTTP 服务器带来了许多可能性,例如使您的产品能够实现以下功能:
-
为同一网络上的用户提供网页,这是为没有显示器的产品提供用户界面的好方法
-
为应用程序和其他设备提供 REST API 进行通信
基本原则
要创建 HTTP 服务器,首先从http模块导入Server类:
import {Server} from "http";
像 HTTP Request类一样,HTTP Server类也配置了一个 dictionary 对象。字典中没有必需的属性。和 HTTP Request一样,HTTP Server使用回调函数在响应 HTTP 请求的不同阶段传递消息。以下是 HTTP 请求各个阶段的完整列表:
-
connection–服务器接受了新的连接。 -
status–HTTP 请求的状态行已收到。请求路径和请求方法可用。 -
header–已收到 HTTP 请求报头。对于收到的每个 HTTP 头,都会重复此消息。 -
headersComplete–在收到最终 HTTP 请求头和请求体之间收到此消息。 -
requestFragment–(仅适用于流式请求正文)请求正文的片段可用。 -
requestComplete–已收到整个请求正文。 -
prepareResponse–服务器准备好开始发送响应。回调返回描述响应的字典。 -
responseFragment–(仅适用于流响应)回调通过提供响应的下一个片段来响应此消息。 -
responseComplete–整个响应已成功交付。 -
error–在 HTTP 响应完全传递之前出现故障。
下面的例子展示了如何使用这些消息。大多数使用 HTTP Server类的应用程序只使用其中的一部分。
回应请求
HTTP 服务器响应各种不同的请求。清单 3-20 摘自$EXAMPLES/ch3-network/http-server-get示例,它用明文响应每个请求,指明响应所用的 HTTP 方法(通常是GET)和请求的 HTTP 资源的路径。方法和路径都通过status消息提供给回调。回调函数存储这些值,以便在接收到prepareResponse消息时将它们返回到文本中。
let server = new Server({port: 80});
server.callback = function(msg, value, etc) {
if (Server.status === msg) {
this.path = value;
this.method = etc;
}
else if (Server.prepareResponse === msg) {
return {
headers: ["Content-Type", "text/plain"],
body: `hello. path "${this.path}".
method "${this.method}".`
};
}
}
Listing 3-20.
运行此示例时,设备的 IP 地址显示在调试控制台中,如下所示:
Wi-Fi connected to "Moddable"
IP address 10.0.1.5
显示 IP 地址后,您可以使用同一网络上的 web 浏览器连接到 web 服务器。当您在浏览器的地址栏中输入http://10.0.1.5/test.html时,您会收到以下响应:
hello. path "/test.html". method "GET".
注意,回调没有设置Content-Length字段。当您使用body属性时,服务器实现会自动添加Content-Length头。
本例中的body属性是一个字符串,但它也可以是一个用二进制数据响应的ArrayBuffer。
正在响应 JSON PUT
通常,REST API 在请求体中以 JSON 的形式接收输入,并在响应体中以 JSON 的形式提供输出。$EXAMPLES/ch3-network/http-server-put示例是一个 JSON echo 服务器,它通过发回消息来回复收到的每条消息。该示例期望客户端使用PUT方法发送一个 JSON 对象。响应将该 JSON 对象嵌入到一个更大的 JSON 对象中,该对象也包含一个error属性。
当收到status消息时,服务器验证它是一个PUT方法;否则,服务器会关闭连接以拒绝请求。当回调接收到status消息时,它返回String,表示它希望整个请求体同时作为一个字符串。改为以二进制数据接收请求体,它可能返回ArrayBuffer。
为了响应requestComplete消息,服务器解析 JSON 输入并将其嵌入到用于生成响应的对象中。当收到prepareResponse消息时,清单 3-21 中的服务器以字符串形式返回响应体 JSON,并将Content-Type头设置为application/json。
let server = new Server;
server.callback = function(msg, value, etc) {
switch (msg) {
case Server.status:
if ("PUT" !== etc)
this.close();
return String;
case Server.requestComplete:
this.json = {
error: "none",
request: JSON.parse(value)
};
break;
case Server.prepareResponse:
return {
headers: ["Content-Type", "application/json"],
body: JSON.stringify(this.json)
};
}
}
Listing 3-21.
因为这个例子没有将字典传递给Server构造函数,所以使用默认的端口 80。
您可以使用下面的命令来尝试使用curl命令行工具的http-server-put示例。您需要更改<IP_address>来匹配您的开发板的 IP 地址(例如,192.168.1.45)。该命令将简单的 JSON 消息以--data参数的形式发送到服务器,并将结果显示到调试控制台。
> curl http://<IP_address>/json
--request PUT
--header "Content-Type: application/json"
--data '{"example": "data", "value": 101}'
接收流式传输请求
当一个大的请求体被发送到 HTTP 服务器时,它可能太大而不适合内存。例如,当您上传数据以存储在文件中时,可能会发生这种情况。解决方案是分段接收请求正文,而不是一次全部接收。来自$EXAMPLES/ch3-network/http-server-streaming-put示例的清单 3-22 向调试控制台记录任意大的文本请求。为了让 HTTP Server类分段传递请求体,回调将true返回给prepareRequest消息。这些片段随requestFragment消息一起交付,并跟踪到调试控制台。requestComplete消息表明所有的请求主体片段已经被交付。
let server = new Server;
server.callback = function(msg, value) {
switch (msg) {
case Server.status:
trace("\n ** begin upload to ${value} **\n");
break;
case Server.prepareRequest:
return true;
case Server.requestFragment:
trace(this.read(String));
break;
case Server.requestComplete:
trace("\n ** end of file **\n");
break;
}
}
Listing 3-22.
您可以修改这个示例,在应用程序需要的地方写入接收到的数据,而不是写入调试控制台。例如,在第五章中,你将学习将数据写入文件的 API。
要尝试这个例子,使用如下所示的curl命令行工具。您需要为您的配置更改<directory_path>和<IP_address>。
> curl --data-binary "@/users/<directory_path>/test.txt"
http://<IP_address>/test.txt -v
发送流响应
如果对 HTTP 请求的响应太大,内存容纳不下,可以用流传输响应。这种方法适用于文件下载。如清单 3-23 所示,$EXAMPLES/ch3-network/http-server-streaming-get示例生成一个随机长度的响应,包含从 1 到 100 的随机整数。为了指示响应体将被流式传输,回调将从prepareResponse消息返回的字典中的body属性设置为true。服务器使用responseFragment消息反复调用回调,以获得响应的下一部分。回调返回undefined表示响应结束。
let server = new Server;
server.callback = function(msg, value) {
if (Server.prepareResponse === msg) {
return {
headers: ["Content-Type", "text/plain"],
body: true
};
}
else if (Server.responseFragment === msg) {
let i = Math.round(Math.random() * 100);
if (0 === i)
return;
return i + "\n";
}
}
Listing 3-23.
这个例子返回响应体的字符串值,但是它也可以返回ArrayBuffer值来提供二进制数据。当收到responseFragment消息时,回调的value参数指示服务器准备接受的这个片段的最大字节数。当您流式传输文件时,这可以用作从文件中读取片段的字节数。
HTTP Server类使用分块传输编码发送流响应正文。对于长度已知的响应正文,服务器使用默认的identity编码发送正文,不包含传输编码头,但包含一个Content-Length头。
mDNS
多播 DNS ,或 mDNS ,是一个功能集合,使设备更容易在本地网络上协同工作。您可能知道 DNS(域名系统)协议,因为它是您的 web 浏览器查找您在地址栏中输入的网站的网络地址的方式(例如,它是浏览器将www.example.com转换为93.184.216.34的方式)。DNS 是为整个互联网而设计的。相比之下,mDNS 只适用于您的本地网络,例如,适用于所有连接到 Wi-Fi 接入点的设备。DNS 是一种集中式设计,依靠权威服务器将域名映射到 IP 地址,而 mDNS 是完全分散的,每台设备都响应将其域名映射到 IP 地址的请求。
在本节中,您将学习如何使用 mDNS 为您的物联网设备命名,如porch-light.local,以便其他设备可以通过名称找到它,而不必知道它的 IP 地址。您还将学习使用 mDNS 的另一部分 DNS-SD (DNS 服务发现)来查找设备提供的服务(例如查找所有打印机或所有 web 服务器)并在本地网络上公布您设备的服务。
mdns模块包含 JavaScript 类,用于在应用程序中处理 mDNS 和 DNS-SD。要在您的代码中使用mdns模块,首先按如下方式导入它:
import MDNS from "mdns";
Note
mDNS 在 macOS、Android、iOS 和 Linux 上都得到了很好的支持。Windows 10 还不完全支持 mDNS,所以你可能需要安装额外的软件才能在那里使用。
要求一个名字
mDNS 通常用于为本地网络中使用的设备命名。mDNS 域名总是在.local域名中,比如在thermostat.local中。您可以为设备选择任何您喜欢的名称。设备必须检查该名称是否已经被使用,因为让多个设备响应同一个名称是行不通的。检查的过程叫做认领 *。*申请过程持续几秒钟。如果发现冲突,mDNS 会定义一个协商过程。协商结束时,只有一台设备拥有请求的名称,而另一台设备选择了未使用的名称。例如,如果你试图认领iotdevice不成功,你可能会以iotdevice-2告终。
$EXAMPLES/ch3-network/mdns-claim-name示例展示了声明名称的过程(参见清单 3-24 )。用一个字典调用MDNS构造函数,该字典包含带有所需名称值的hostName属性。有一个回调函数在申请过程中接收进度消息。当收到带有非空的value的name消息时,所声明的名称被追踪到调试控制台。
let mdns = new MDNS({
hostName: "iotdevice"
},
function(msg, value) {
if ((MDNS.hostName === msg) && value)
trace(`Claimed name ${value}.\n`);
}
);
Listing 3-24.
一旦设备声明了名称,您就可以使用该名称来访问该设备。例如,您可以使用ping命令行工具来确认设备是否在线。
> ping iotdevice.local
寻找服务
通过声明一个名字,你的设备变得更容易交流,但是这个名字最多只能提供一点关于这个设备做什么的提示。了解设备是灯、恒温器、扬声器还是 web 服务器会很有帮助,这样您就可以在没有任何配置的情况下编写与它一起工作的代码。这就是 DNS-SD 解决的问题:这是一种在本地网络上宣传您的物联网产品功能的方式。
每种 DNS-SD 服务都有一个唯一的名称。例如,web 服务器服务名为http,网络文件系统名为nfs。$EXAMPLES/ch3-network/mdns-discover的例子展示了如何搜索所有在本地网络上发布广告的 web 服务器。网络上可能有您不知道的 web 服务器,因为许多打印机都有用于配置和管理的内建 web 服务器。
如清单 3-25 所示,mdns-discover示例创建了一个MDNS实例,但没有声明名称。它安装了一个监控回调函数,当发现一个http服务时会通知它。对于找到的每个服务,它都会向设备的主页发出 HTTP 请求,并跟踪其 HTTP 头到调试控制台。
let mdns = new MDNS;
mdns.monitor("_http._tcp", function(service, instance) {
trace(`Found ${service}: "${instance.name}" @ ` +
`${instance.target} ` +
`(${instance.address}:${instance.port})\n`);
let request = new Request({
host: instance.address,
port: instance.port,
path: "/"
});
request.callback = function(msg, value, etc) {
if (Request.header === msg)
trace(` ${value}: ${etc}\n`);
else if (Request.responseComplete === msg)
trace("\n\n");
else if (Request.error === msg)
trace("error \n\n");
};
});
Listing 3-25.
回调函数的instance参数有几个用于设备的属性:
-
name–设备的可读名称 -
target–设备的 mDNS 名称(例如lightbulb.local) -
address–设备的 IP 地址 -
port–用于连接服务的端口
以下是该示例找到一台带有http服务的惠普打印机时的输出:
Found _http._tcp: "HP ENVY 7640 series"
@hpprinter.local (192.168.1.223:80)
server: HP HTTP Server; HP ENVY 7640 series - E4W44A;
content-type: text/html
last-modified: Mon, 23 Jul 2018 10:53:51 GMT
content-language: en
content-length: 658
为服务做广告
您的设备可以使用 DNS-SD 来宣传它提供的服务,这使得同一网络上的其他设备能够找到并使用这些服务。
$EXAMPLES/ch3-network/mdns-advertise示例定义了它在变量httpService中存储的 JavaScript 对象中提供的服务。服务描述说这个例子支持http服务,并使它在端口 80 上可用。清单 3-26 定义了 DNS-SD 的 HTTP 服务。
let httpService = {
name: "http",
protocol: "tcp",
port: 80
};
Listing 3-26.
然后,该示例创建一个MDNS实例来声明名称server。一旦名称被声明,清单 3-27 中的脚本将添加http服务。在声明名称之前不能添加服务,因为 DNS-SD 要求每个服务与一个 mDNS 名称相关联。
let mdns = new MDNS({
hostName: "server"
},
function(msg, value) {
if ((MDNS.hostName === msg) && value)
mdns.add(httpService);
}
);
Listing 3-27.
添加服务后,其他设备可能会找到它,如前面的“查找服务”一节所示
完整的mdns-advertise示例还包含一个简单的 web 服务器,它监听端口 80。当您运行这个示例时,您可以在 web 浏览器中输入server.local来查看 web 服务器的响应。
WebSocket
当您需要在设备之间频繁进行双向通信时, WebSocket 协议是 HTTP 的一个很好的替代方案。当两个设备使用 WebSocket 进行通信时,它们之间的网络连接保持打开,从而能够高效地进行简短消息的通信,例如发送传感器读数或开灯命令。在 HTTP 中,一个设备是客户端,一个是服务器;只有客户端可以发出请求,服务器总是会响应。另一方面,WebSocket 是一个对等协议,使两个设备都能够发送和接收消息。对于需要发送很多小消息的物联网产品,往往是个不错的选择。但是,因为它在两个设备之间始终保持连接,所以它通常比 HTTP 需要更多的内存。
WebSocket 协议由websocket模块实现,该模块同时包含 WebSocket 客户端和 WebSocket 服务器支持。您的项目可以根据需要导入一个或两个。
import {Client} from "websocket";
import {Server} from "websocket";
import {Client, Server} from "websocket";
因为 WebSocket 是一个对等协议,所以客户端和服务器的代码非常相似。主要区别在于初始设置。
连接到 WebSocket 服务器
$EXAMPLES/ch3-network/websocket-client示例使用了一个 WebSocket echo 服务器,它通过发回消息来回复收到的每条消息。WebSocket Client类构造函数接受一个配置字典。唯一需要的属性是host,服务器的名称。如果没有指定port属性,则假定 WebSocket 默认值为 80。
let ws = new Client({
host: "echo.websocket.org"
});
您可以通过为Socket属性传递SecureSocket来使用 TLS 建立一个安全的连接,正如前面“将 TLS 与SecureSocket类一起使用”一节中所解释的
您提供了一个回调函数来接收来自 WebSocket Client类的消息。WebSocket 协议比 HTTP 简单,所以回调也更简单。在websocket-client示例中,connect和close消息只是跟踪一条消息。WebSocket 协议的连接过程由两个步骤组成:当客户端和服务器之间建立网络连接时接收到connect消息,当客户端和服务器同意使用 WebSocket 进行通信时接收到handshake消息,表示连接已准备就绪。
当示例接收到handshake消息时,它发送第一条消息,一个带有count和toggle属性的 JSON 字符串。当 echo 服务器发回那个 JSON 时,清单 3-28 中的回调会用receive消息调用。它将字符串解析回 JSON,修改count和toggle值,并将修改后的 JSON 发送回 echo 服务器。这个过程无限重复,每次增加count。
ws.callback = function(msg, value) {
switch (msg) {
case Client.connect:
trace("connected\n");
break;
case Client.handshake:
trace("handshake success\n");
this.write(JSON.stringify({
count: 1,
toggle: true
}));
break;
case Client.receive:
trace(`received: ${value}\n`);
value = JSON.parse(value);
value.count += 1;
value.toggle = !value.toggle;
this.write(JSON.stringify(value));
break;
case Client.disconnect:
trace("disconnected\n");
break;
}
}
Listing 3-28.
下面是这段代码的输出:
connected
handshake success
received: {"count":1,"toggle":true}
received: {"count":2,"toggle":false}
received: {"count":3,"toggle":true}
received: {"count":4,"toggle":false}
...
对write的每个调用发送一个 WebSocket 消息。您可以在收到handshake消息后的任何时间发送消息,而不仅仅是从回调内部:
ws.write("hello");
ws.write(Uint8Array.of(1, 2, 3).buffer);
消息要么是字符串,要么是ArrayBuffer。当您收到一个 WebSocket 消息时,它要么是一个字符串,要么是一个ArrayBuffer,这取决于发送的内容。清单 3-29 展示了如何检查接收到的消息value的类型。
if (typeof value === "string")
...; // a string
if (value instanceof ArrayBuffer)
...; // an ArrayBuffer, binary data
Listing 3-29.
创建 WebSocket 服务器
$EXAMPLES/ch3-network/websocket-server示例实现了一个 WebSocket echo 服务器(也就是说,每当服务器接收到一条消息时,它都会发回相同的消息)。WebSocket Server类是用没有必需属性的字典配置的。可选的port属性表示监听新连接的端口;默认为 80。
let server = new Server;
清单 3-30 中的服务器回调函数接收与客户端相同的消息。在这个例子中,所有的消息都只是跟踪调试控制台的状态,除了receive,它将接收到的消息回显。
server.callback = function(msg, value) {
switch (msg) {
case Server.connect:
trace("connected\n");
break;
case Server.handshake:
trace("handshake success\n");
break;
case Server.receive:
trace(`received: ${value}\n`);
this.write(value);
break;
case Server.disconnect:
trace("closed\n");
break;
}
}
Listing 3-30.
这个服务器支持多个同时连接,当回调被调用时,每个连接都有一个惟一的this值。如果您的应用程序需要跨连接维护状态,它可以向this添加属性。当一个新的连接建立时,接收到connect消息;当连接结束时,接收到disconnect消息。
MQTT
消息队列遥测传输协议,或 MQTT ,是一种发布-订阅协议,旨在供轻量级物联网客户端设备使用。服务器(在 MQTT 中有时称为“代理”)更复杂,因此通常不会在资源受限的设备上实现。进出 MQTT 服务器的消息被组织成*主题。*一个特定的服务器可能支持许多主题,但是一个客户端只接收它订阅的主题的消息。
MQTT 协议的客户端由mqtt模块实现:
import MQTT from "mqtt";
连接到 MQTT 服务器
MQTT构造函数由一个带有三个必需参数的字典配置而成:host属性表示要连接的 MQTT 服务器,port是要连接的端口号,id是这个设备的惟一 ID。具有相同 ID 的两个设备连接到 MQTT 服务器是错误的,所以要注意确保它们是真正唯一的。清单 3-31 中的$EXAMPLES/ch3-network/mqtt示例摘录使用设备的 MAC 地址作为唯一 ID。
let mqtt = new MQTT({
host: "test.mosquitto.org",
port: 1883,
id: "iot_" + Net.get("MAC")
});
Listing 3-31.
如果 MQTT 服务器需要认证,那么user和password属性将被添加到配置字典中。密码总是二进制数据,所以清单 3-32 使用ArrayBuffer.fromString静态方法将字符串转换为ArrayBuffer。
let mqtt = new MQTT({
host: "test.mosquitto.org",
port: 1883,
id: "iot_" + Net.get("MAC"),
user: "user name",
password: ArrayBuffer.fromString("secret")
});
Listing 3-32.
要使用加密的 MQTT 连接,可以像前面“使用 TLS 保护连接”一节中描述的那样,通过向字典添加一个Socket属性和可选的secure属性来使用 TLS。
一些服务器使用 WebSocket 协议来传输 MQTT 数据。如果您使用的是这样的服务器,您需要指定path属性来告诉 MQTT 类端点要连接到哪个类,如清单 3-33 所示。通过 WebSocket 连接传输 MQTT 没有任何好处,并且会使用更多的内存和网络带宽,因此只有在远程服务器需要时才应该使用它。
let mqtt = new MQTT({
host: "test.mosquitto.org",
port: 8080,
id: "iot_" + Net.get("MAC"),
path: "/"
});
Listing 3-33.
MQTT 客户机有三个回调函数(清单 3-34 )。当成功建立到服务器的连接时,调用onReady回调;当收到消息时,调用onMessage;当连接丢失时,调用onClose。
mqtt.onReady = function() {
trace("connection established\n");
}
mqtt.onMessage = function(topic, data) {
trace("message received\n");
}
mqtt.onClose = function() {
trace("connection lost\n");
}
Listing 3-34.
一旦调用了onReady回调,MQTT 客户机就可以订阅消息主题和发布消息了。
订阅主题
要订阅主题,请向服务器发送要订阅的主题的名称。您的客户端可以通过多次调用subscribe来订阅多个客户端。
mqtt.subscribe("test/string");
mqtt.subscribe("test/binary");
mqtt.subscribe("test/json");
对于您的客户端已经订阅的所有主题,消息都被传递到onMessage回调函数。topic参数是主题的名称,data参数是完整的消息。
mqtt.onMessage = function(topic, data) {
trace(`received message on topic "${topic}"\n`);
}
data参数总是以二进制形式提供,作为一个ArrayBuffer。如果知道消息是字符串,可以转换成字符串;如果知道字符串是 JSON,可以将其转换成 JavaScript 对象。
data = String.fromArrayBuffer(data);
data = JSON.parse(data);
String.fromArrayBuffer是 XS 的一个特性,使应用程序更容易处理二进制数据。有一个并行的ArrayBuffer.fromString功能。这些都不是 JavaScript 语言标准的一部分。
发布到主题
要向主题发送消息,请使用字符串或ArrayBuffer调用publish:
mqtt.publish("test/string", "hello");
mqtt.publish("test/binary", Uint8Array.of(1, 2, 3).buffer);
要发布 JSON,首先将其转换为字符串:
mqtt.publish("test/json", JSON.stringify({
message: "hello",
version: 1
}));
网络时间协议
简单网络时间协议,或者 SNTP ,是一种检索当前时间的轻量级方法。您的电脑可能使用 SNTP(或其父 NTP)在幕后设置时间。与你的物联网设备不同,你的电脑也有一个由电池支持的实时时钟,所以它总是知道当前时间。如果您需要物联网设备上的当前时间,您需要检索它。如果您使用命令行方法连接到 Wi-Fi,并且您在命令行上指定了时间服务器,则一旦建立 Wi-Fi 连接,就会检索当前时间。
> mcconfig -d -m -p esp ssid="my wi-fi" sntp="pool.ntp.org"
当用代码连接 Wi-Fi 时,你还需要写一些代码来设置你的物联网设备的时钟。您使用 SNTP 协议获得当前时间,该协议在sntp模块中实现,您使用time模块设置设备的时间。
import SNTP from "sntp";
import Time from "time";
清单 3-35 显示了$EXAMPLES/ch3-network/sntp在 pool.ntp.org 向时间服务器请求当前时间的例子。当收到时间时,设备的时间被设置并在调试控制台中以 UTC(协调世界时)显示。由于不再需要,SNTP实例会关闭自己来释放它正在使用的资源。
new SNTP({
host: "pool.ntp.org"
},
function(msg, value) {
if (SNTP.time !== msg)
return;
Time.set(value);
trace("UTC time now: ",
(new Date).toUTCString(), "\n");
}
);
Listing 3-35.
大多数物联网产品都有一个 SNTP 服务器列表,以备其中一个不可用时使用。SNTP类支持这种场景,而不需要创建SNTP类的额外实例。参见可修改的 SDK 中的examples/network/sntp示例,了解如何使用这个故障转移特性。
高级主题
本节介绍两个高级主题:如何将您的设备变成一个私人 Wi-Fi 基站,以及如何将 JavaScript promises 与网络 API 结合使用。
创建 Wi-Fi 接入点
有时,您不想将您的物联网产品连接到整个互联网,但您确实希望让人们连接到您的设备来配置它或检查它的状态。在其他时候,您确实想将您的设备连接到互联网,但是您还没有 Wi-Fi 接入点的名称和密码。在这两种情况下,创建一个私人 Wi-Fi 接入点可能是一个解决方案。除了作为连接到其他接入点的 Wi-Fi 客户端之外,许多物联网微控制器(包括 ESP32 和 ESP8266)也可以作为接入点。
您可以通过调用WiFi类的静态accessPoint方法,将您的物联网设备变成接入点:
WiFi.accessPoint({
ssid: "South Village"
});
ssid属性定义了接入点的名称,并且是唯一必需的属性。如清单 3-36 所示,可选属性使您能够设置密码,选择要使用的 Wi-Fi 信道,并隐藏接入点使其不出现在 Wi-Fi 扫描中。
WiFi.accessPoint({
ssid: "South Village",
password: "12345678",
channel: 8,
hidden: false
});
Listing 3-36.
设备可以是接入点,也可以是接入点的客户端。两者不能同时存在,所以一旦进入接入点模式,就无法访问互联网。
您可以在接入点上提供一个 web 服务器,如前面的“响应请求”一节所示在清单 3-37 中,从$EXAMPLES/ch3-network/accesspoint的例子来看,HTTP Server类的导入有一点不同,因为它将类重命名或别名为HTTPServer,以避免与 DNS 服务器的名称冲突(在下面的例子中介绍)。
import {Server as HTTPServer} from "http";
(new HTTPServer).callback = function(msg, value) {
if (HTTPServer.prepareResponse === msg) {
return {
headers: ["Content-Type", "text/plain"],
body: "hello"
};
}
}
Listing 3-37.
其他设备如何知道您的 web 服务器的地址以便连接到它?你可以用 mDNS 来命名一个地方。但由于你的物联网产品是接入点,它现在也是网络的路由器,所以它可以解析 DNS 请求。这意味着每当网络上的一个设备查找一个名字,比如www.example.com,您的应用程序就可以将请求定向到您的 HTTP 服务器。清单 3-38 是一个简单的 DNS 服务器,它就是这样做的。
import {Server as DNSServer} from "dns/server";
new DNSServer(function(msg, value) {
if (DNSServer.resolve === msg)
return Net.get("IP");
});
Listing 3-38.
DNS Server类构造函数将回调函数作为其唯一的参数。每当连接到接入点的任何设备试图解析 DNS 名称时,回调函数就用resolve消息调用。作为响应,回调提供自己的 IP 地址。当大多数电脑或手机连接到新的 Wi-Fi 点时,它们会执行检查以查看它们是否已连接到互联网,或者是否需要登录。当在您的访问点上执行此检查时,它将导致您的 web 服务器的访问点被调用以显示网页。在这个例子中,它将简单地显示hello,但是你可以改变它来显示设备状态,配置 Wi-Fi,或者任何你喜欢的东西。
承诺和异步函数
承诺是 JavaScript 的一个特性,可以用回调函数简化编程。回调函数简单而高效,这也是为什么它们被广泛使用的原因。承诺可以提高使用回调函数执行一系列步骤的代码的可读性。
本节并不是对承诺和异步函数的完整介绍。如果您不熟悉这些 JavaScript 特性,请通读这一部分,看看它们是否对您的项目有用;如果是的话,网上有很多优秀的资源可以帮助你了解更多。
清单 3-39 中的$EXAMPLES/ch3-network/http-get-with-promise示例摘录基于 HTTP Request类来实现一个fetch函数,该函数以字符串形式返回一个完整的 HTTP 请求。
function fetch(host, path = "/") {
return new Promise((resolve, reject) => {
let request = new Request({host, path, response: String});
request.callback = function(msg, value) {
if (Request.responseComplete === msg)
resolve(value);
else if (Request.error === msg)
reject(-1);
}
});
}
Listing 3-39.
fetch函数的实现很复杂,需要深入理解承诺在 JavaScript 中是如何工作的。但是使用fetch函数很容易(列出 3-40 )。
function httpTrace(host, path) {
fetch(host, path)
.then(body => trace(body, "\n"))
.catch(error => trace("http get failed\n"));
}
Listing 3-40.
阅读httpTrace的代码,您可能会认为 HTTP 请求是同步发生的,但事实并非如此,因为所有的网络操作都是非阻塞的。当请求完成时,传递给.then和.catch调用的箭头函数被执行——如果调用成功,则执行.then,如果调用失败,则执行.catch。
JavaScript 的最新版本提供了另一种编写代码的方式:作为异步函数。清单 3-41 显示了在异步函数中重写的对fetch的调用。除了关键字async和await,代码看起来像普通的 JavaScript。
async function httpTrace(host, path) {
try {
let body = await fetch(host, path);
trace(body, "\n");
}
catch {
trace("http get failed\n");
}
}
Listing 3-41.
httpTrace函数是异步的,所以它在被调用时会立即返回。在调用fetch之前的关键字await告诉 JavaScript 语言,当fetch返回一个承诺时,应该暂停httpTrace的执行,直到承诺准备好(解决或拒绝)。
承诺和异步函数是强大的工具,它们在 JavaScript 代码中用于更强大的系统,包括 web 服务器和计算机。它们可用于您的物联网项目,甚至在资源受限的设备上,因为您使用的是 XS JavaScript 引擎。尽管如此,在大多数情况下,回调函数是首选,因为它们需要更少的代码,执行速度更快,并且使用更少的内存。在构建项目时,您需要确定使用它们的便利性是否超过了所使用的额外资源。
结论
在本章中,您已经了解了物联网设备通过网络进行通信的各种方式。本章中描述的不同协议都遵循相同的基本 API 模式:
-
协议的类提供了一个构造器,它接受一个字典来配置连接。
-
回调函数将信息从网络传递到应用程序。
-
通信总是异步的,以避免阻塞,这是物联网产品的一个重要考虑因素,这些产品并不总是拥有多线程执行的奢侈。
-
使用小的助手函数可以将回调转化为承诺,这样应用程序就可以使用现代 JavaScript 中的异步函数。
你作为一个物联网产品的开发者,需要决定它支持的通信方式。有许多因素要考虑。如果您希望您的设备与云通信,HTTP、WebSocket 和 MQTT 都是可能的选择,它们都支持使用 TLS 的安全通信。对于直接的设备到设备通信,mDNS 是一个很好的起点,使设备能够宣传它们的服务,而 HTTP 是一种在设备之间交换消息的轻量级方法。
当然,您的产品不必只选择一种网络协议进行通信。从本章的例子开始,你已经准备好尝试不同的 protools 来找到最适合你的设备需求的。
四、蓝牙低能耗(BLE)
有许多方法可以实现设备之间的无线通信。第三章介绍了许多通过 Wi-Fi 连接与世界上任何地方的设备通信的协议。本章重点介绍蓝牙低能耗或 BLE ,这是一种广泛应用于两个相互靠近的设备之间的无线通信。如果最大限度地减少能源使用特别重要,例如在电池供电的产品中,以及当与另一设备(如手机)直接通信是互联网接入的可接受替代方案时,产品会选择使用 BLE 而不是 Wi-Fi。从心率监测器到电动牙刷到烤箱,许多物联网产品都使用 BLE。产品制造商通常提供移动应用程序或桌面配套应用程序来监控或控制这些产品。
BLE 是蓝牙标准的第 4 版,于 2010 年首次推出。最初的蓝牙是在 2000 年标准化的,用于短距离发送数据流。BLE 显著降低了原始蓝牙的能耗,使其在单次充电后能够工作更长时间。BLE 部分通过减少传输的数据量来实现这一点。传输距离越短,消耗的能量也越少;BLE 设备的覆盖范围通常不超过 100 米,而 Wi-Fi 的覆盖范围要大得多。BLE 的低功耗和低成本使其非常适合许多物联网产品。
使用本章中的信息,你可以构建自己的运行在微控制器上的 BLE 设备。
Note
本章中的示例适用于 ESP32。如果您尝试为 ESP8266 构建它们,构建将会失败,因为 ESP8266 没有 BLE 硬件。然而,这些例子确实可以在其他集成了可修改的 SDK 支持的 BLE 的设备上运行,包括高通的 QCA4020 和硅实验室的 Blue Gecko。
BLE 基础知识
如果您是使用 BLE 的新手,本节中的信息是必不可少的,因为它解释了本章其余部分中使用的概念。如果你熟悉 BLE,仍然可以考虑快速浏览一下这一部分,以熟悉本书中使用的术语,以及它与可修改 SDK 的 BLE API 的关系。
gap 中心和外围设备
通用访问配置文件或间隙定义了设备如何自我宣传,它们如何彼此建立连接,以及安全性。GAP 定义的两个主要角色是中枢和外围 。
中央扫描充当外围设备的设备,并发起与外围设备建立新连接的请求。充当中央设备的设备通常具有相对较高的处理能力和大量内存,例如智能手机、平板电脑或电脑,而外围设备通常体积较小,使用电池供电。外围设备自我广告并接受建立连接的请求。
BLE 规范允许一个中心连接到多个外围设备,一个外围设备连接到多个中心。中央处理器同时连接到几个外围设备是很常见的。例如,您可以使用智能手机连接到您的心率监测器、智能手表和灯。一个外围设备同时连接到多个中央设备是不常见的;大多数外设不允许多个并发连接。可修改的 SDK 的 BLE API 允许外围设备一次连接一个中央设备。
GATT 客户端和服务器
通用属性配置文件,或 GATT ,定义了 BLE 设备之间建立连接后来回传输数据的方式——客户端-服务器关系。
一个 GATT 客户端是一个通过发送读/写请求从远程 GATT 服务器访问数据的设备。一个 GATT 服务器是一个本地存储数据,接收读/写请求,并通知远程 GATT 客户机其特性值的变化的设备。在本章中,术语服务器用于表示 GATT 服务器,而客户机表示 GATT 客户机。
GAP 与关贸总协定
许多 BLE 教程错误地将术语中央和客户端互换使用,并将术语外围设备和服务器互换使用。这是因为中央系统通常承担客户端角色,而外围设备通常承担服务器角色。然而,BLE 规范指出,中心或外围设备可以担当客户机、服务器或两者的角色。
中央和外围是由 GAP 定义的术语,告诉您 BLE 连接是如何管理的。客户端和服务器是 GATT 定义的术语,告诉你连接建立后数据的存储和流动。只有在 GAP 所定义的广告和连接过程完成之后,GATT 才会出现。
概况、服务和特征
关贸总协定还定义了数据的格式,有一个层次的*简档、服务、和特征。*如图 4-1 所示,层级的顶层是一个概要。
图 4-1
关贸总协定概况层次结构
轮廓
一个配置文件定义了 BLE 在多个设备间通信的特定用途,包括相关设备的角色及其一般行为。例如,标准健康温度计配置文件定义了温度计设备(传感器)和收集器设备的角色;温度计装置测量温度,收集器装置从温度计接收温度测量值和其他数据。该配置文件指定温度计必须实例化的服务(健康温度计服务和设备信息服务),并声明该配置文件的预期用途是在医疗保健应用中。
BLE 设备上不存在配置文件;相反,它们是由 BLE 设备实现的规范。官方采用的 BLE 简介列表可在 bluetooth.com/specifications/gatt 获得。当您实现自己的 BLE 设备时,最好检查是否有满足您产品需求的标准配置文件。如果有的话,您将受益于与支持该标准的其他产品的互操作性,从而节省您设计新概要文件的时间。
服务
一个服务是描述 BLE 设备一部分行为的特征集合。例如,健康温度计服务提供来自温度计设备的温度数据。服务可以具有一个或多个特征,并通过 UUID 来区分。官方采用的 BLE 服务有 16 位 UUIDs。健康温度计服务的编号为0x1809。您可以通过为它们提供 128 位 UUID 来创建自己的自定义服务。
服务由 BLE 设备进行广告。官方采用的 BLE 服务列表可在 bluetooth.com/specifications/gatt/services 获得。
特征
一个特征是一个提供 GATT 服务信息的单个值或数据点。数据的格式取决于特征;例如,心率服务使用的心率测量特征提供整数形式的心率测量值,而设备名称特征提供字符串形式的设备名称。
官方采用的 BLE 特征列表可在 bluetooth.com/specifications/gatt/characteristics 获得。与服务一样,官方采用的 BLE 特征有 16 位 UUID,您可以使用 128 位 UUID 创建自己的 uuid。
可修改的 SDK 的 BLE API
对于 GAP 和 GATT 定义的角色,可修改的 SDK 在其 BLE API 中没有明确的类。相反,它在单个BLEClient类中为 Centrals 和 GATT 客户机提供功能,在单个BLEServer类中为外设和 GATT 服务器提供功能。此类组织反映了 BLE 设备的两种最常见的配置:充当中心并承担客户端角色的设备,以及充当外围设备并承担服务器角色的设备。
BLEClient类
BLEClient类提供了用于创建 Centrals 和 GATT 客户端的函数。中心的功能执行以下操作:
-
扫描外围设备。
-
发起与外围设备建立连接的请求。
GATT 客户端的函数执行这些操作:
-
找到感兴趣的关贸总协定服务。
-
在每项服务中寻找感兴趣的特征。
-
读取、写入和启用每个服务中特征的通知。
您可以子类化BLEClient类来实现特定的 BLE 设备,该设备支持您的设备所需的操作。子类调用BLEClient类的方法来启动前面的操作。由BLEClient执行的所有 BLE 操作都是异步的,以避免在不确定的时间内阻塞执行。因此,BLEClient类的实例通过回调接收结果。例如,BLEClient类有一个startScanning方法,您可以调用它来开始扫描外设,还有一个onDiscovered回调函数,当发现外设时会自动调用。
您只需要实现处理设备所需的外围设备、服务和特性所需的回调。
BLEServer类
BLEServer类提供了用于创建外设和 GATT 服务器的函数。外围设备的功能执行以下操作:
-
广告以便中心可以发现外围设备。
-
接受来自中心的连接请求。
GATT 服务器的功能执行这些操作:
-
部署服务。
-
响应来自客户端的特征读取和写入请求。
-
接受来自客户端的特征值更改通知请求。
-
通知客户特征值的变化。
您可以实施标准的 BLE 模式,如心率或您自己定制的模式,以支持您产品的独特功能。在这两种情况下,首先在 JSON 文件中定义 GATT 服务,然后子类化BLEServer类来实现特定的 BLE 设备。子类调用BLEServer类的方法来启动前面的操作。由BLEServer执行的所有 BLE 操作都是异步的,以避免在不确定的时间内阻塞执行。因此,BLEServer类的实例通过回调接收结果。
安装 BLE 主机
本章中的示例使用第一章中描述的模式进行安装:您使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。
主机在$EXAMPLES/ch4-ble/host目录中。从命令行导航到这个目录,用mcconfig安装它。
创建 BLE 扫描仪
$EXAMPLES/ch4-ble/scanner示例实现了一个中心,它扫描附近的外围设备,并跟踪它们的名称到控制台。它是使用BLEClient类实现的。清单 4-1 展示了这个例子的大部分源代码。
class Scanner extends BLEClient {
onReady() {
this.startScanning();
}
onDiscovered(device) {
let scanResponse = device.scanResponse;
let completeName = scanResponse.completeName;
if (completeName)
trace(`${completeName}\n`);
}
}
Listing 4-1.
Scanner类实现了两个BLEClient回调:
-
当 BLE 栈准备好使用时,调用
onReady回调。在这个例子中,onReady回调函数调用startScanning来扫描附近的外设。 -
对于每个被发现的外设,
onDiscovered回调被调用一次或多次。在这个例子中,onDiscovered回调跟踪发现的外围设备的名称到控制台。
在这个简单的例子中,您的中心会发现您周围的外围设备,并告诉您它们的名称。现在您可以更进一步了:下一个例子演示了如何使用BLEClient类的其他特性来创建一个与虚拟外设通信的 BLE 设备。
创造双向交流
$EXAMPLES/ch4-ble/text-client示例实现了一个连接到外设并通过特征值变化通知接收文本数据的中心。
要查看示例的工作情况,您需要一个提供文本数据特征的外设。你可以使用 Bluefruit 创建一个,这是一个在 iOS 和 Android 设备上免费提供的移动应用程序。要创建外设,采取以下步骤,如图 4-2 和 4-3 所示:
图 4-2
蓝果的周边模式
- 下载打开 Bluefruit,进入外设模式。在广告信息部分,将本地名称字段更改为
esp。
图 4-3
UART 服务使能
- 确定 UART 服务已打开。
接下来的部分解释了在 ESP32 上运行以实现中央设备的代码。
连接到外围设备
应用程序顶部的常数对应于您在 Bluefruit 中设置的设备名称以及 UART 服务使用的服务和特征 UUID:
const PERIPHERAL_NAME = 'esp';
const SERVICE_UUID = uuid`6E400001B5A3F393E0A9E50E24DCCA9E`;
const CHARACTERISTIC_UUID = uuid`6E400003B5A3F393E0A9E50E24DCCA9E`;
和scanner例子一样,这个例子实现了onReady和onDiscovered回调,如清单 4-2 所示。但是这个例子并不只是跟踪设备的名称到控制台,而是检查每个发现的外围设备的名称,看它是否与PERIPHERAL_NAME常量匹配。如果是,它停止扫描外围设备并调用connect方法,该方法在BLEClient和目标外围设备之间发起连接请求。
class TextClient extends BLEClient {
onReady() {
this.startScanning();
}
onDiscovered(device) {
if (PERIPHERAL_NAME ===
device.scanResponse.completeName) {
this.stopScanning();
this.connect(device);
}
}
...
Listing 4-2.
connect的参数是Device类的一个实例,代表一个外围设备。BLEClient在发现外设时自动创建Device类的实例;应用程序不会直接实例化它们。然而,应用程序确实与Device类的实例直接交互——例如,通过调用方法来执行 GATT 服务和特征发现。
onConnected回调
onConnected方法是当中央处理器连接到外设时调用的回调。这个例子调用了device对象的discoverPrimaryService方法来从外围设备获取主要的 GATT 服务。discoverPrimaryService的参数是要发现的服务的 UUID。
onConnected(device) {
device.discoverPrimaryService(SERVICE_UUID);
}
您可以使用discoverAllPrimaryServices方法发现外围设备的所有主要服务。例如,onConnected回调可以写成如下形式:
onConnected(device) {
device.discoverAllPrimaryServices();
}
onServices回调
onServices方法是服务发现完成时调用的回调。services参数是一组service对象——Service类的实例——每个对象都提供对单个服务的访问。如果调用discoverPrimaryService来查找单个服务,服务数组只包含找到的一个服务。
如清单 4-3 所示,该示例检查外设是否提供了与SERVICE_UUID常量定义的 UUID 相匹配的服务。如果是的话,它调用service对象的discoverCharacteristic方法来寻找 UUID 与CHARACTERISTIC_UUID常量定义的服务特征相匹配的服务特征。
onServices(services) {
let service = services.find(service =>
service.uuid.equals(SERVICE_UUID));
if (service) {
trace(`Found service\n`);
service.discoverCharacteristic(CHARACTERISTIC_UUID);
}
else
trace(`Service not found\n`);
}
Listing 4-3.
您可以使用discoverAllCharacteristics方法发现所有的服务特征。例如,onServices回调可以用下面的代码行替换调用discoverCharacteristic的代码行:
service.discoverAllCharacteristics();
onCharacteristics回调
onCharacteristics方法是在特性发现完成时调用的回调。characteristics参数是一组characteristic对象——Characteristic类的实例——每个对象都提供对单个服务特征的访问。如果调用discoverCharacteristic来查找单个特征,那么特征数组包含所找到的单个特征。
当找到所需的特性时,该示例调用characteristic对象的enableNotifications方法,以在特性的值改变时启用通知,如清单 4-4 所示。
onCharacteristics(characteristics) {
let characteristic = characteristics.find(characteristic => characteristic.uuid.equals(CHARACTERISTIC_UUID));
if (characteristic) {
trace(`Enabling notifications\n`);
characteristic.enableNotifications();
}
else
trace(`Characteristic not found\n`);
}
Listing 4-4.
如果您正确设置了外围设备,当您运行text-client应用程序时,您将在调试控制台中看到以下消息:
Found service
Enabling notifications
接收通知
启用通知后,您可以通过从智能手机更改外围设备的特征值来向客户端发送通知。要更改该值,点击 UART 按钮。这将带您进入图 4-4 所示的屏幕。在屏幕底部的输入字段中输入文本,点击发送更新特征值。
图 4-4
Bluefruit 中的 UART 屏幕
特征值通过ArrayBuffer中的onCharacteristicNotification回调传递给客户端。这个例子假设值是一个字符串,所以它使用String.fromArrayBuffer(XS 的一个特性,使应用程序更容易处理二进制数据)将ArrayBuffer转换成一个字符串。有一个平行的ArrayBuffer.fromString。这些都不是 JavaScript 语言标准的一部分。
onCharacteristicNotification(characteristic, buffer) {
trace(String.fromArrayBuffer(buffer)+"\n");
}
创建心率监测器
现在您已经了解了实现从服务器接收通知的客户机的基本知识,这个例子将向您展示如何使用BLEServer类的特性来实现一个外围设备,该外围设备在连接到中央服务器之后承担服务器的角色。
$EXAMPLES/ch4-ble/hrm示例宣传标准心率和电池服务,接受来自 Centrals 的连接请求,发送模拟心率值的通知,并响应来自客户端的模拟电池电量的读取请求。接下来的几节解释了如何使用BLEServer类实现它。
定义和部署服务
GATT 服务是在位于主机的bleservices目录中的 JSON 文件中定义的。JSON 在构建时会自动转换成特定于平台的本机代码,编译后的目标代码会链接到应用程序。
每个 GATT 服务都是在自己的 JSON 文件中定义的。清单 4-5 显示了标准心率服务。
{
"service": {
"uuid": "180D",
"characteristics": {
"bpm": {
"uuid": "2A37",
"maxBytes": 2,
"type": "Array",
"permissions": "read",
"properties": "notify"
}
}
}
}
Listing 4-5.
以下是对一些重要属性的解释:
-
对象的属性是 GATT 规范分配给服务的号码。心率服务有 UUID
180F。 -
characteristics对象描述了服务支持的每个特征。每个直接属性是一个特征的名称。在这个例子中,只有一个特征:bpm,代表每分钟的节拍数。 -
一个
characteristic对象的uuid属性是由 GATT 规范分配给特征的唯一数字。心率服务的bpm特征有 UUID2A37。 -
属性指定了 JavaScript 代码中使用的特征值的类型。
BLEServer类使用type属性的值将客户端传输的二进制数据转换为 JavaScript 类型。这为您的服务器代码节省了在不同类型的数据之间来回转换的工作(ArrayBuffer、String、Number等等)。 -
permissions属性定义特性是只读、只写还是读/写,以及访问特性是否需要加密连接。bpm属性是只读的,因为在心率监视器中,每分钟的心跳数是由传感器读数决定的,因此不能由客户端写入。read权限表示客户端可以通过未加密或加密的连接读取特征;当值只能通过加密连接访问时,使用readEncrypted。类似地,使用write或writeEncrypted获得写权限。要表明某个特征同时支持读取和写入,请在权限字符串中包含读取和写入值,用逗号分隔,例如"readEncrypted,writeEncrypted"。 -
properties属性定义了特征的属性。可能是read、write、notify、indicate,或者是这些的组合(逗号分隔)。read和write值允许读取和写入特征值,notify允许服务器通知客户端特征值的变化,而无需请求和确认通知已被接收,并且indicate与notify相同,除了它需要确认通知已被接收,然后才能发送另一个指示。
一旦 BLE 栈完成初始化,它就调用onReady回调。onReady的hrm示例实现发起广告,使其服务能够被客户端发现。下一节解释了当广告激活时子类如何管理。
广告
外围设备广播广告数据来宣告它们自己。BLEServer类有一个startAdvertising方法开始广播广告包,还有一个stopAdvertising方法停止广播。
当 BLE 栈准备好使用时,以及当到中心的连接丢失时,hrm示例调用startAdvertising。当startAdvertising被调用时,外设广播它的广告数据类型标志值、它的名称和它的服务(心率和电池),如清单 4-6 所示。心率和电池服务的 UUIDs 来自 GATT 规范。
this.startAdvertising({
advertisingData: {
flags: 6,
completeName: this.deviceName,
completeUUID16List: [uuid`180D`, uuid`180F`]
}
});
Listing 4-6.
当成功建立到中心的连接时,外围设备停止发送广告数据包,因为它一次只支持一个连接:
onConnected() {
this.stopAdvertising();
}
当连接丢失时,外围设备再次开始广告。
BLE 广告可能包含附加数据,例如,实现 BLE 信标。BLE 信标为许多中心做广告,让他们不用连接就能看到数据。清单 4-7 中的代码来自可修改 SDK 中的examples/network/ble/uri-beacon示例,它实现了一个为可修改网站做广告的 UriBeacon。这里的 UUID 来自于赋值的数字规范(见 bluetooth.com/specifications/assigned-numbers/16-bit-uuids-for-members )。encodeData方法以 UriBeacon 规范指定的格式对 URI 进行编码。源代码见uri-beacon示例。
this.startAdvertising({
advertisingData: {
completeUUID16List: [uuid`FED8`],
serviceDataUUID16: {
uuid: uuid`FED8`,
data: this.encodeData("http://www.moddable.com")
}
}
});
Listing 4-7.
广告数据只传输数据;没有办法回复。双向通信要求一台设备承担 GATT 客户端角色,另一台设备承担 GATT 服务器角色。在这发生之前,由 GAP 定义的连接过程必须完成。
建立连接
一旦心律外围设备开始发布广告,它就会等待中央设备请求与其连接。你可以使用各种各样的移动应用程序来创建一个这样的中心。在本节中,您将使用 LightBlue,它可以在 iOS 和 Android 设备上免费获得。LightBlue 具有中央模式,使您能够扫描、连接外围设备并向其发送读/写请求。通过执行以下步骤,您可以将其用作外围设备的客户端:
图 4-5
浅蓝色外设列表中的心率监测器外设
-
在您的 ESP32 上运行该示例。
-
下载并打开 LightBlue,等待心率监测器外设出现,如图 4-5 所示。
-
轻触心率监测器外围设备,与其建立连接。
当中央处理器连接到心率外设时,会调用onConnected回调。在本例中,它停止广播广告并响应扫描响应包,如清单 4-8 所示。
class HeartRateService extends BLEServer {
...
onConnected() {
this.stopAdvertising();
}
...
}
Listing 4-8.
发送通知
BLE 客户端可以请求具有notify属性的特征的通知,例如本例中的bpm特征。启用通知时,服务器会通知客户端特性值的更改,而无需服务器请求该值。通知节省了能量,这是 BLE 设计的一个关键特性,因为它消除了客户端轮询服务器以了解特征变化的需要。
要接收浅蓝色的模拟心率通知,请采取以下步骤(如图 4-6 、 4-7 和 4-8 所示):
图 4-6
带有心率测量按钮的心率监护仪特征屏幕
- 点击心率测量特性。
图 4-7
带有收听通知按钮的心率测量屏幕
- 轻触收听通知以启用通知。
图 4-8
出现在通知值部分的心率值
- 观察模拟心率值的出现。
现在让我们看看在服务器端实现心率服务通知的代码。onCharacteristicNotifyEnabled方法是一个回调函数,当客户端启用某个特性的通知时,就会调用这个函数。onCharacteristicNotifyDisabled方法是一个回调函数,当客户端禁用某个特性的通知时会调用这个函数。两者的characteristic参数都是Characteristic类的一个实例,它提供了对单个服务特征的访问。
onCharacteristicNotifyEnabled方法(如清单 4-9 所示)调用notifyValue方法,该方法以 1000 毫秒(1 秒)的间隔向连接的客户端发送特征值变化通知。这模拟了心率传感器,尽管真实的心率监测器不会发送定期更新;相反,当值实际改变时,它会发送一个通知。
onCharacteristicNotifyEnabled(characteristic) {
this.bump = +1;
this.timer = Timer.repeat(id => {
this.notifyValue(characteristic, this.bpm);
this.bpm[1] += this.bump;
if (this.bpm[1] === 65) {
this.bump = -1;
this.bpm[1] = 64;
}
else if (this.bpm[1] === 55) {
this.bump = +1;
this.bpm[1] = 56;
}
}, 1000);
}
Listing 4-9.
onCharacteristicNotifyDisabled方法(清单 4-10 )通过调用stopMeasurements方法结束通知的发送。
onCharacteristicNotifyDisabled(characteristic) {
this.stopMeasurements();
}
...
stopMeasurements() {
if (this.timer) {
Timer.clear(this.timer);
delete this.timer;
}
this.bpm = [0, 60]; // flags, beats per minute
}
Listing 4-10.
响应读取请求
客户端可能会请求支持read属性的特征值,例如本例中的电池服务。要发送读取浅蓝色模拟电池电量值的请求,请采取以下步骤(如图 4-9 、 4-10 和 4-11 所示):
图 4-9
带电池电量按钮的心率监测器特征屏幕
- 点击电池电量特性。
图 4-10
电池电量屏幕,带有再次读取按钮
- 点击再次读取。
图 4-11
显示在读取值部分的电池电量值
- 观察模拟电池电量的显示。
现在让我们来看看处理电池电量服务通知的代码。onCharacteristicRead方法(清单 4-11 )是一个回调函数,当客户端根据需要读取服务特征值时会调用它。BLEServer实例负责处理读请求。在本例中,电池电量从 100 开始;每次读取该值时,回调函数都会返回值并减 1。
onCharacteristicRead(params) {
if (params.name === "battery") {
if (this.battery === 0)
this.battery = 100;
return this.battery--;
}
}
Listing 4-11.
建立安全通信
可修改的 SDK 支持蓝牙核心规范 4.2 版中引入的增强安全特性:通过数字比较、密钥输入和 Just Works 配对方法实现安全连接。BLEClient和BLEServer类都有一个可选的securityParameters属性,请求设备建立一个 LE 安全连接。使用的配对方法取决于设备的功能和在securityParameters属性中设置的选项。安全回调函数由BLEClient和BLEServer类托管。下一节将介绍一个简单的例子。
安全心率监测器
$EXAMPLES/ch4-ble/hrm-secure示例是$EXAMPLES/ch4-ble/hrm示例的安全版本,它需要输入密码进行配对。
同样,您可以使用 LightBlue 作为客户端。按照与之前相同的步骤,当心率监测器提示您输入密码时(如图 4-12 ,在xsbug中输入追踪到控制台的密钥(如图 4-13 )。
图 4-13
调试控制台中的密钥
图 4-12
以浅蓝色提示输入代码
现在,您可以像以前一样启用心率值通知并按需读取电池值,但服务器和客户端之间的连接是安全的。
这段代码与$EXAMPLES/ch4-ble/hrm的例子有一些不同。如清单 4-12 所示,onReady回调包含配置设备安全需求和外设 I/O 能力的附加代码。
this.securityParameters = {
bonding: true,
mitm: true,
ioCapability: IOCapability.DisplayOnly
};
Listing 4-12.
此代码中的属性指定了以下内容:
-
属性启用绑定,这意味着两个设备将在下次连接时存储和使用它们交换的密钥。如果不启用绑定,设备每次连接时都必须进行配对。
-
mitm属性请求中间人保护,这意味着两个配对设备之间交换的数据被加密,以防止不受信任的设备窃听。 -
ioCapability属性指示与确认密钥相关的设备的用户界面功能。这个设备没有显示器,但是它有显示功能,因为它可以跟踪调试控制台。其他外围设备可能具有更多的输入/输出能力(例如,带有键盘的设备)或更少的输入/输出能力(例如,无法显示文本的设备)。两个设备的ioCapability属性用于确定配对方法。例如,如果两个设备都没有键盘或显示器,则使用 Just Works 配对方法。
实现了另外两个BLEServer类的回调函数(参见清单 4-13 ):
-
当您试图建立与外设的连接时,会调用
onPasskeyDisplay回调。在这种情况下,当您点击浅蓝色外围设备的名称时,就会调用它。正如您之前看到的,这个示例跟踪调试控制台的密钥。 -
设备配对成功后会调用
onAuthenticated回调。这个例子简单地改变了authenticated属性来表明一个安全的连接已经建立。
onPasskeyDisplay(params) {
let passkey = this.passkeyToString(params.passkey);
trace(`server display passkey: ${passkey}\n`);
}
onAuthenticated() {
this.authenticated = true;
}
Listing 4-13.
当客户端启用通知时,服务器检查是否设置了authenticated属性。if块中的代码看起来与hrm示例中的onCharacteristicNotifyEnabled方法相同。
onCharacteristicNotifyEnabled(characteristic) {
if (this.authenticated) {
...
}
服务器还定义了一个额外的助手方法,名为passkeyToString。密钥值是整数,向用户显示时必须始终包含六位数字。此方法在必要时用前导零填充密钥以供显示。
passkeyToString(passkey) {
return passkey.toString().padStart(6, "0");
}
结论
现在你已经理解了这些例子的要点,你可以在你的 ESP32 上用 BLE 做很多事情。您可以连接到家中的 BLE 产品,而不是连接到您在 LightBlue 中创建的虚拟外围设备。您可以从自己喜欢的现成传感器发送实际的传感器数据,而不是像心率监测器示例那样发送模拟数据。
如果你想尝试更多 BLE 的例子,可以在 GitHub 上的 Moddable SDK 中查看examples/network/ble目录。让您的设备成为 URI 传输信标、将您的设备与 iPhone 音乐应用程序配对,等等。如果你想了解更多关于可修改的 SDK 的 BLE API,请看documentation/network/ble目录。