返回博客首页
← 所有文章

使用 NativeScript 蓝牙控制机器人

2017 年 5 月 2 日 — 作者:Sebastian Witalec

简介

现在,组建一支机器人军队比以往任何时候都容易。
您只需要一支像 Wowwee Mip 这样的听话机器人,以及一个控制它们的应用程序。

Mip 使用 BLE(蓝牙低功耗)作为通信协议。

在本文中,我们将探讨蓝牙的核心概念,如何使用 `nativescript-bluetooth` 以及如何构建一个机器人应用程序。
我们还将尝试在适用的情况下使用 rxjs 来增强应用程序。

本文将重点关注使用蓝牙的机制,但不会提供任何 UI 代码。要查看完整的示例,请参阅 nativescript-mip-simple-ng


解释蓝牙核心概念

服务、特征、指令

蓝牙命令分为一组 服务,例如 仪表盘服务武器服务驱动控制服务。然后每个服务包含一组 特征,例如 仪表盘服务 包含 速度指示特征油量指示特征

每个服务要么用于接收 读取指令 来提供反馈(仪表盘服务及其特征),要么用于接收 写入指令 来执行操作(武器和驱动控制服务)。

请注意,每个服务和特征都可以使用其 UUID 进行标识。默认服务通常为 4 个字符长(例如,fa00),而其他服务则更长,看起来像这样 9a66fb00-0800-9191-11e4-012d1540cb8e
https://npmjs.net.cn/package/nativescript-mip-ble


与 BLE 设备通信的整体步骤

无论您使用什么技术与 BLE 设备通信,都必须遵循类似的一组步骤。
  • 确保蓝牙已启用且可用
  • 扫描区域内的 BLE 设备
  • 允许用户选择设备并连接
  • 使用服务 + 特征发送读取/写入命令

使用 Wowee Mip 机器人

Wowwee MiP robot

出于本文的目的,我们将使用 Wowwee Mip

为了向 MiP 机器人发送指令,我们需要使用 serviceUUID = 'fe05'characteristicUUID = 'ffe9'
Wowee 还准备了 MiP 协议文档,其中列出了所有可用的命令。

例如,该协议提供了如何更改胸部 LED 颜色 的描述。
它包含该指令的代码 0x84,以及所需的三个字节值参数:红色、绿色和蓝色。

Set Chest Led

因此,如果我们想使用 RGB 值 FFAA88 调用它,该命令应类似于:'0x84,0xFF,0xAA,0x88'


NativeScript 蓝牙插件 API

在 NativeScript 中,用于蓝牙通信的最佳插件是 nativescript-bluetooth。它允许您执行所有需要执行的操作来指挥一支机器人军队。


设置

首先,我们需要将该插件添加到您的 NativeScript 项目中。

tns plugin add nativescript-bluetooth


要求

然后,我们需要请求该模块。

对于 Vanilla JS,请使用

var bluetooth = require("nativescript-bluetooth");

对于 TypeScript(强烈推荐),请使用

import bluetooth = require('nativescript-bluetooth');


检查蓝牙是否可用

在我们开始与设备通信之前,我们需要


首先检查蓝牙是否已启用。

为此,只需调用 isBluetoothEnabled,它将返回一个包含布尔标志的 promise。

bluetooth.isBluetoothEnabled().then(
  enabled => console.log("Enabled? " + enabled)
);

rxjs
我们可以使用 rxjs 稍微增强一下,创建一个 Observable,每当蓝牙启用或禁用时,它都会发出一个事件。

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/distinctUntilChanged';
 
listenToBluetoothEnabled(): Observable<boolean> {
  return new Observable(observer => {
    bluetooth.isBluetoothEnabled()
      .then(enabled => observer.next(enabled))
 
    let intervalHandle = setInterval(
      () => {
        bluetooth.isBluetoothEnabled()
          .then(enabled => observer.next(enabled))
      }
      , 1000);
 
    // 取消订阅时停止每秒检查
    return () => clearInterval(intervalHandle);
  })
  .distinctUntilChanged();
}

然后订阅它,以便我们收到有关更改的通知

this.listenToBluetoothEnabled()
.subscribe(enabled => this.isBluetoothEnabled = enabled);

这为我们提供了一种优雅的机制来处理这种情况,并在蓝牙被禁用时提示用户启用蓝牙。


第二步,检查/请求应用程序权限以使用蓝牙

此步骤仅适用于 Android,因为 iOS 会自动处理此步骤。

要检查权限,请调用:bluetooth.hasCoarseLocationPermission(),它将返回一个包含结果的 promise。

bluetooth.hasCoarseLocationPermission()
.then(granted =>
  console.log("Has Location Permission? " + granted);
);

如果为 false,则应通过调用 bluetooth.requestCoarseLocationPermission() 请求权限。

提示
我倾向于跳过权限检查,并在加载应用程序的第一个页面时始终调用请求函数。如果权限已授予,该插件足够智能,可以忽略我的请求。
例如,在 Angular 项目中,我从
ngOnInit 中执行此操作,如下所示:

ngOnInit() {
  bluetooth.requestCoarseLocationPermission();
}


搜索

蓝牙准备好后,我们就可以开始搜索设备了。

为此,我们需要使用 startScanning 函数,并提供要扫描的时长(以 为单位)以及每个发现的设备的回调函数,如 `onDiscovered`。

我们从 onDiscovered 中获得的两个最重要的信息是
  • peripheral.name - 可用于显示给用户进行选择
  • peripheral.UUID - 此设备的 UUID,用于连接到该设备。
我们将使用 onDiscovered 的结果来填充 devices 数组,您可以使用它在 listview 中显示数据。

private devices: any[] = [];
 
scan() {
  this.devices = [];
 
  bluetooth.startScanning({
    seconds: 3,
    onDiscovered: (peripheral: Peripheral) => {
      if(peripheral.name) {
        console.log(`UUID: ${peripheral.UUID} name: ${peripheral.name}`)
        this.devices.push(peripheral);
      }
    }
  })
}


连接

如果一切顺利,您应该会看到设备列表,包括我们的 MiP 机器人。
要连接到它,我们需要调用 connect 函数,并提供
  • UUID - 设备的 UUID
  • onConnected 函数 - 一个回调函数,在连接完全建立时调用。使用此函数导航到包含设备控制的视图(例如,包含 goForward、goBack 按钮的视图)
  • onDisconnected 函数 - 一个回调函数,在设备断开连接时调用。使用此函数返回扫描器视图

connect(UUID: string) {
  bluetooth.connect({
    UUID: UUID,
    onConnected: (peripheral: Peripheral) => {
      alert('Connected');
      // 在此处您可以导航到控制器视图
    },
    onDisconnected: (peripheral: Peripheral) => {
      alert('Device Disconnected');
      // 在此处您可以导航到扫描视图
    }
  })
}


扫描页面完整示例

有关如何检查权限、搜索和连接的完整示例,请参阅 mip-scan.component


发送命令

连接到机器人后,我们可以开始发送一些指令了。

根据协议,我们可以使用以下参数告诉机器人向前和向后移动。
Move Forward and Back

要以 20 的速度向前移动 500 毫秒,我们需要提供以下值

属性 十六进制
指令代码 0x71 0x71
速度 20 0x14
时间 500/7 = 71 0x47

我们可以通过两种不同的方式提供值
  • 作为逗号分隔的十六进制值的字符串,例如:value: '0x71,0x14,0x47'
  • 作为Uint8Array,包含数值,例如:value: new Uint8Array([0x71,0x14,0x47])value: new Uint8Array([0x71,20,71])

提示
字符串似乎是硬编码值的不错选择,但是Uint8Array 当我们需要处理来自UI的持续值流时,会更好。

要发出命令,我们需要调用writewriteWithoutResponse函数,它们接受
  • serviceUUID - 在我们的例子中是ffe5
  • characteristicUUID - 在我们的例子中是ffe9
  • peripheralUUID - 在我们的例子中是连接到的机器人的UUID
moveForward() {
  bluetooth.writeWithoutResponse({
    serviceUUID: 'ffe5',
    characteristicUUID: 'ffe9',
    peripheralUUID: this.deviceUUID,
    value: '0x71,0x14,0x47'
  })
}
 
moveBack() {
  bluetooth.writeWithoutResponse({
    serviceUUID: 'ffe5',
    characteristicUUID: 'ffe9',
    peripheralUUID: this.deviceUUID,
    value: new Uint8Array([0x72,0x20,0x40])
  })
}

提示
writeWithoutResponsewrite 快得多,因为它不等待设备的响应。大多数情况下,你应该使用 writeWithoutResponse。只有当你想等待命令被连接的设备接收和处理时,你才应该使用 write。对于MiP机器人来说,没有必要这样。

当我使用
Parrot Mambo无人机 之前,在发送任何飞行指令之前,我必须先通过发送4个配置命令来初始化它。每个命令必须在前面的命令完成后发送,这意味着我需要使用 write 并等待一个Promise,直到我可以调用下一个命令。


连续驱动

我相信从这一点你就可以弄清楚如何让机器人左转和右转。
四个带时间的驱动命令很容易实现,但是它们不能给你最好的驾驶体验。幸运的是,机器人还有另一个功能叫做连续驱动,它允许你每50毫秒发送一次驾驶指令,以获得更流畅的驾驶体验。

Continuous Drive

由于我们要多次调用连续驱动,所以最好把它封装在一个优雅的函数中。它将接受速度转弯属性(范围从-1到1),将它们转换为十六进制,然后调用writeWithoutResponse函数。

注意,要向前移动,我们需要发送0到0x20之间的值,要后退,我们需要发送0x21到0x40之间的值。要向左转,我们需要发送0x61到0x80之间的值,要向右转,我们需要发送0x41到0x60之间的值

/**
* 将速度和转弯值转换为MiP期望的十六进制值
* 并发出连续驱动命令
* @param speed 向前移动[0到1],后退[-1到0]
* @param turn 左转[-1到0],右转[0到1]
*/
continuousDrive(speed: number, turn: number) {
  const mipSpeed = (speed > 0) ? this.convertTo0x20(speed, 0) : this.convertTo0x20(speed, 0x20);
  const mipTurn = (turn > 0) ? this.convertTo0x20(turn, 0x40) : this.convertTo0x20(turn, 0x60);
 
  bluetooth.writeWithoutResponse({
    serviceUUID: this.serviceUUID,
    characteristicUUID: this.characteristicUUID,
    peripheralUUID: this.deviceUUID,
    value: new Uint8Array([0x78, mipSpeed, mipTurn])
  });
}
 
convertTo0x20(val: number, offset: number) {
  if(val < 0) {
    val = -val;
  }
 
  return Math.floor(val * 0x20) + offset;
}

现在我们可以在循环中调用它,使机器人以圆形的方式向前左转。

setInterval(
  () => this.continuousDrive(0.5, -0.7)
  ,50);

或者,如果你有速度转弯参数,你可以从UI更新它们,你可以像这样调用它

setInterval(
  () => this.continuousDrive(this.speed, this.turn)
  ,50);


加速度计

但实际上,要充分利用连续驱动,我们需要一些东西,让用户能够持续地向机器人提供反馈。

这就是nativescript-accelerometer插件的作用,因为它允许我们通过倾斜手机来控制机器人。

首先,让我们将插件添加到我们的项目中。

tns plugin add nativescript-accelerometer

使用加速度计相当简单。插件提供了两个函数
  • startAccelerometerUpdates - 它接受一个回调函数,返回手机位置,包含x、y和z坐标
  • stopAccelerometerUpdates - 它停止加速度计更新
然而,加速度计的更新频率与我们对连续驱动所需的每50毫秒不同。为了解决这个问题,我们将使用加速度计更新来更新转弯(使用data.x)和速度(使用data.y`)属性,并单独启动一个带`setInterval`的时间循环,该循环将每50毫秒调用一次连续驱动

然后,我们需要一个单独的函数,允许我们停止加速度计更新和时间循环

private loopHandle: number = null;
 
startAccelerometerInterval() {
  // 如果加速度计已处于活动状态,则忽略
  if (this.loopHandle) {
    return;
  }
 
  let turn: number = 0;
  let speed: number = 0;
 
  // 启动加速度计更新
  startAccelerometerUpdates(
    (data: AccelerometerData) => {
      turn = data.x;  // 向左倾斜 [-1到0] 向右倾斜 [0到1]
      speed = data.y; // 向后倾斜 [-1到0] 向前倾斜 [0到1]
    }
  );
 
  // 启动时间循环
  this.loopHandle = setInterval(() => {
    this.continuousDrive(speed, turn);
  }, 50);
}
 
stopAccelerometerInterval() {
  if(this.loopHandle) {
    stopAccelerometerUpdates();
 
    clearInterval(this.loopHandle);
 
    this.loopHandle = null;
  }
}

现在,如果你调用startAccelerometerInterval函数,你应该可以通过向前或向后倾斜它来使机器人向前或向后移动。要让它转弯,只需将它向左或向右倾斜。
多么酷啊?

带有rxjs Observable的加速度计

为了更刺激,让我们将加速度计功能封装在rxjs Observable中。
我们需要我们的Observable启动加速度计更新并发出所有值,并且在取消订阅时,它应该停止更新。

在订阅之前,我们需要使用 **sampleTime(50)**,它只会在每隔 50 毫秒时获取当前值,并忽略其间发射的所有其他值。
然后我们可以 **订阅**,并在每次更新时调用 **continuousDrive**。
请记住捕获从 **subscribe** 调用返回的 **订阅对象**。

停止更新很简单,只要调用 **订阅对象** 上的 **unsubscribe** 即可,因为取消订阅将负责停止加速度计更新。

startAccelerometerRxjs() {
  // 创建一个发射加速度计数据的 Observable
  const accelerometer$ = new Observable<AccelerometerData>(observer => {
    startAccelerometerUpdates(
      (data: AccelerometerData) => {
        // 为每次更新发射值
        observer.next(data);
      }
    )
 
    // 在取消订阅时停止加速度计
    return () => stopAccelerometerUpdates();
  });
 
  this.accelerometerSubscription = accelerometer$
  // 每 50 毫秒接收当前值
  .sampleTime(50)
  // 订阅更新
  .subscribe(data => {
    this.continuousDrive(data.y, data.x)
  });
}
 
stopAccelerometerRxjs() {
  this.accelerometerSubscription.unsubscribe();
}


控制器页面完整示例

有关如何使用带时间函数的驱动以及两种加速度计方法使机器人四处移动的完整示例,请参见 mip-controller.component


总结

使用 NativeScript 与机器人通信非常容易。唯一限制你的因素是机器人的 API 和你的想象力。
如果你发现这篇文章很有趣,并且想给你的朋友留下深刻印象,你应该给自己买一个支持 BLE 通信的机器人(在我的情况下是 **Wowwee MiP**),然后动手试试。

有关其他示例,你可以查看我的插件,该插件包装了 MiP 的大部分功能。参见 nativescript-mip-ble.

请记住公式:**扫描** -> **连接** -> **命令**