在这一节我们来设计和实现 IotHub 的 NTP 服务。

NTP

什么是 NTP?这个我想大家都不陌生,NTP 是同步网络中各个计算机时间的一种协议。在 IotHub 中,保证设备和服务端的时间同步是非常重要的,比如指令的有效期设置就非常依赖于设备和服务器间的时间同步,如果设备时间不准确,就有可能导致过期的指令仍然被执行。
通常情况下,设备上都应该运行一个 NTP 的服务,定时地和 NTP 服务器进行时间同步(IotHub 服务器端也使用同样的 NTP 服务器进行时间同步),这样在绝大部分情况下,都可以保证设备和 IotHub 服务器端的时间是同步的,除非设备掉电或者断网。

IotHub 的 NTP 服务

某些嵌入式设备上,系统可能没有自带 NTP 服务,或者因为设备资源有限,无法运行 NTP 服务,这个时候 IotHub 需要基于现有的数据通道,实现一个类似于 NTP 服务器的时间同步功能,来满足上述情景下的设备与 IotHub 的时间同步。
IotHub 的 NTP 服务实现流程如下:

  1. 设备发起数据请求,请求 NTP 对时,请求中包含当前的设备时间 deviceSendTime。
  2. IotHub 收到 NTP 对时的请求下,通过下发指令的方式将收到 NTP 对时请求的时间 IotHubRecvTime、IotHub 发送指令的时间 IotHubSendTime 以及 deviceTime 发送到设备。
  3. 设备收到 NTP 对时指令后,记录当前时间 deviceRecvTime,然后通过公式(IotHubRecvTime + IotHubSendTime + deviceRecvTime - deviceSendTime)/ 2,来获取当前的精确时间。时间的单位都为毫秒。

这个流程没有涉及业务系统,这里的数据请求和指令下发都只存在于 IotHub 和设备之间,我们把这样的数据请求和指令都定义为 IotHub 的内部请求和指令,他们有以下的一些特点:

  • 数据请求的 resource 以 $ 开头;
  • 指令下发的指令名以 $ 开头;
  • Payload 格式统一为 JSON 格式。

    这也就意味着业务系统不能发送以 $开头的指令;设备应用代码也不能通过 sendDataRequest 接口来发送$ 开头的请求;在调用时需要对输入参数进行校验,本课程为了演示起见,跳过了输入参数校验的部分,在实际项目中是不能漏掉的。

设备端实现

在 DeviceSDK 端,首先要实现发送 NTP 对时请求的功能,NTP 对时请求用数据请求的接口实现,这里我们约定 NTP 对时请求的 Resource 为 $ntp:

  1. //IotHub_Device/sdk/iot_device.js
  2. sendNTPRequest() {
  3. this.sendDataRequest("$ntp", JSON.stringify({device_time: Date.now()}))
  4. }

然后在收到 IotHub 下发的 NTP 对时指令时进行正确计算,这里我们约定 NTP 对时的下发指令名为 $set_ntp:

  1. //IotHub_Device/sdk/iot_device.js
  2. handleCommand({commandName, requestID, encoding, payload, expiresAt, commandType = "cmd"}) {
  3. if (expiresAt == null || expiresAt > Math.floor(Date.now() / 1000)) {
  4. ...
  5. if (commandName.startsWith("$")){
  6. if(commandName == "$set_ntp"){
  7. this.handleNTP(payload)
  8. }
  9. } else {
  10. this.emit("command", commandName, data, respondCommand)
  11. }
  12. }
  13. }

在处理内部指令时,DeviceSDK 不会通过”command”事件将内部指令的信息传递给设备应用代码。可能有的读者会觉得 if (commandName.startsWith("$"))这个判断是多余的,毕竟后面还要按照指令名去对比。这个是有必要的,如果说 IotHub 的功能升级了,增加了新的内部命令,不做这个判断的话,当新的内部命令发给还未升级的 DeviceSDK 设备时,就会把内部命令暴露给设备应用代码。
最后需要计算当前的准确时间,再传递给设备应用代码:

  1. //IotHub_Device/sdk/iot_device.js
  2. handleNTP(payload) {
  3. var time = Math.floor((payload.iothub_recv + payload.iothub_send + Date.now() - payload.device_time) / 2)
  4. this.emit("ntp_set", time)
  5. }

设备应用代码再捕获 “ntp_set” 事件来设置系统时间。

服务端实现

服务端的实现很简单,收到 NTP 数据请求以后,将公式中需要的几个时间用指令的方式下发给设备:

  1. static handleDataRequest({productName, deviceName, resource, payload, ts}) {
  2. if(resource.startsWith("$")){
  3. if(resource == "$ntp"){
  4. this.handleNTP(payload, ts)
  5. }
  6. }else {
  7. NotifyService.notifyDataRequest(...)
  8. }
  9. }

这里的检查if(resource.startsWith("$"))和 DeviceSDK 中类似,当 IotHub 弃用了某个内部数据请求时,如果不检查的话,使用还未升级的 DeviceSDK 设备可能会导致这个弃用的数据请求被转发业务系统。
因为 NTP 要使用收到消息的时间,所以这里添加了 ts 参数。
接下来将 NTP 对时指令下发给设备:

  1. //IotHub_Server/services/message_service.js
  2. static handleNTP({payload, ts, productName, deviceName}) {
  3. var data = {
  4. device_time: payload.device_time,
  5. iothub_recv: ts * 1000,
  6. iothub_send: Date.now()
  7. }
  8. Device.sendCommand({
  9. productName: productName,
  10. deviceName: deviceName,
  11. data: JSON.stringify(data),
  12. commandName: "$set_ntp"
  13. })
  14. }

由于 EMQ X WebHook 传递过来的 ts 单位是秒,所以这里的计算会有误差,我们在后面再来解决这个问题。

代码联调

接下来我们写一段代码来验证一下:

  1. //IotHub_Device/samples/ntp.js
  2. ...
  3. device.on("online", function () {
  4. console.log("device is online")
  5. })
  6. device.on("ntp_set", function (time) {
  7. console.log(`going to set time ${time}`)
  8. })
  9. device.connect()
  10. device.sendNTPRequest()

运行 ntp.js, 可以看到以下输出:

device is online
going to set time 1559569382108

那么 IotHub 的 NTP 服务功能就完成了。

这一节,我们完成了 IotHub 的 NTP 服务,接下来的几节课,我们来设计和实现设备的分组功能。