阅读 796

[译] 如何在浏览器中编写一款蓝牙应用

原文An Introduction To WebBluetooth
作者Niels 发表时间:february 13, 2019
译者:西楼听雨 发表时间: 2019/02/24 (转载请注明出处)

这里省略一段开篇介绍,太长,不是什么干货,直接跳过不翻译了,想看的读者可以前往原文查看

WebBluetooth is a new specification that has been implemented in Chrome and Samsung Internet that allows us to communicate directly to Bluetooth Low Energy devices from the browser. Progressive Web Apps in combination with WebBluetooth offer the security and convenience of a web application with the power to directly talk to devices.

WebBlutetooth(Web 蓝牙)是一项已经在 Chrome 和 Samsung Internet (三星浏览器) 中被实现的新规范,它可以让我们直接在浏览器中与低功耗蓝牙设备进行通讯。渐进式网页应用配合 WebBluetooth 为可以直接与设备进行通讯的网页应用提供了安全保障和便利。

Bluetooth has a pretty bad name due to limited range, bad audio quality, and pairing problems. But, pretty much all those problems are a thing of the past. Bluetooth Low Energy is a modern specification that has little to do with the old Bluetooth specifications, apart from using the same frequency spectrum. More than 10 million devices ship with Bluetooth support every single day. That includes computers and phones, but also a variety of devices like heart rate and glucose monitors, IoT devices like light bulbs and toys like remote controllable cars and drones.

蓝牙由于它有限的输入距离、较差的音频质量以及配对问题,背负了一个不好的名声。但其实,所有这些问题都已经成为了过去。Bluetooth Low Energy (低功耗蓝牙) 是一项与以往的蓝牙规范没有什么关系的现代化的规范——除了都使用了同样的频段以外。每天会有超过千万的设备配备了蓝牙,这些设备不仅包括了手机和电脑,还包括了各种各样的如心率、血糖监视器,还有物联网设备如灯泡,玩具如遥控车、飞行器等。

枯燥的理论部分

Since Bluetooth itself is not a web technology, it uses some vocabulary that may seem unfamiliar to us. So let’s go over how Bluetooth works and some of the terminology.

由于蓝牙本身并不是一项 Web 技术,它会用到一些对我们来说可能并不熟悉的词汇。所以接下来我们就来看一下它是怎么工作的以及它的一些术语。

Every Bluetooth device is either a ‘Central device’ or a ‘Peripheral’. Only central devices can initiate communication and can only talk to peripherals. An example of a central device would be a computer or a mobile phone.

每个蓝牙设备,要么是“中心设备”,要么是“外围设备”。只有中心设备才可以发起通讯,而且只能与外围设备进行通讯。电脑和手机就是中心设备的一个例子。

A peripheral cannot initiate communication and can only talk to a central device. Furthermore, a peripheral can only talk to one central device at the same time. A peripheral cannot talk to another peripheral.

外围设备是不能发起通讯的,也只能与中心设备进行通讯;而且,外围设备在同一时间只能与一个中心设备通讯。外围设备不能与另一个外围设备进行通讯。

a phone in the middle, talking to multiple peripherals, such as a drone, a robot toy, a heart rate monitor and a lightbulb

A central device can talk to multiple peripherals at the same time and could relay messages if it wanted to. So a heart rate monitor could not talk to your lightbulbs, however, you could write a program that runs on a central device that receives your heart rate and turns the lights red if the heart rate gets above a certain threshold.

中心设备可以与多个外围设备同时通讯,也可以对消息进行中继。所以,虽然心率监控器不能与你的灯泡通讯,但是,你可以编写一个运行在中心设备上的程序,让他接收你的心率并在心率达到特定阈值时将灯光变红。

When we talk about WebBluetooth, we are talking about a specific part of the Bluetooth specification called Generic Attribute Profile, which has the very obvious abbreviation GATT. (Apparently, GAP was already taken.)

当我们在谈论 WebBluetooth 时,其实我们谈论的是蓝牙规范中的一个特定的叫做 Generic Attribute Profile (通用属性协议——译注) 的部分,简称 GATT (貌似是因为 GAP 已经被占用而这样简称)

In the context of GATT, we are no longer talking about central devices and peripherals, but clients and servers. Your light bulbs are servers. That may seem counter-intuitive, but it actually makes sense if you think about it. The light bulb offers a service, i.e. light. Just like when the browser connects to a server on the Internet, your phone or computer is a client that connects to the GATT server in the light bulb.

在 GATT 的语境下,我们不再称中心设备和外围设备了,而是改称为客户端和服务端。你的灯泡就是服务端,这看上去有点反直觉,但如果你认真思考一下就会发现这实际上是有其意义的。灯泡提供了一项服务,即“光”,就像浏览器连接到服务器一样,你的手机或电脑就是一个连接到了这个灯泡里的 GATT 服务端的客户端。

Each server offers one or more services. Some of those services are officially part of the standard, but you can also define your own. In the case of the heart rate monitor, there is an official service defined in the specification. In case of the light bulb, there is not, and pretty much every manufacturer tries to re-invent the wheel. Every service has one or more characteristics. Each characteristic has a value that can be read or written. For now, it would be best to think of it as an array of objects, with each object having properties that have values.

每个服务端可以提供一项或多项服务。这些服务,有些是属于官方标准的一部分,但你也可以定义属于你自己的服务。对于心率监视器来说,已经有一项官方的服务在规范中存在了;而对于灯泡来说,还没有,所以几乎所有厂商都会尝试“重复造轮子”。每项服务又有一个或多个特性(characteristic)。每项特性都有一个可以被读写的值。在现在来看,把它想象成一个对象数组最好理解,每个对象都有自己的属性和值。

the hierarchy of services and characteristics compared to more familiar constructs from JavaScript - a server is similar to an array of objects, a service to an object in that array, a characteristic to a property of that object and both have values

Unlike properties of objects, the services and characteristics are not identified by a string. Each service and characteristic has a unique UUID which can be 16 or 128 bits long. Officially, the 16 bit UUID is reserved for official standards, but pretty much nobody follows that rule. Finally, every value is an array of bytes. There are no fancy data types in Bluetooth.

和对象的属性不一样,服务项和特性不是用字符串来标识的。每项服务和每个特性都有一个 16 或 128 位比特长的唯一的 UUID。官方规定,16 比特的 UUID 用来保留在各项官方标准上,但几乎没有人遵守这项规定。最后要说的就是,每个特性值都是一个字节数组——在蓝牙中没有所谓的什么数据类型。

近距离观察一个蓝牙灯泡

So let’s look at an actual Bluetooth device: a Mipow Playbulb Sphere. You can use an app like BLE Scanner, or nRF Connect to connect to the device and see all the services and characteristics. In this case, I am using the BLE Scanner app for iOS.

下面我们来看一下一个真实的蓝牙设备:一台 Mipow 牌的灯光球。你可以使用 BLE Scanner 或者 nRF Connect 这类 APP 来连接这台设备并查看它的所有服务项和特性。这里我使用的是 BLE Scanner 应用的 iOS 版。

视频演示地址(需越墙):vimeo.com/303046505

The first thing you see when you connect to the light bulb is a list of services. There are some standardized ones like the device information service and the battery service. But there are also some custom services. I am particularly interested in the service with the 16 bit UUID of 0xff0f. If you open this service, you can see a long list of characteristics. I have no idea what most of these characteristics do, as they are only identified by a UUID and because they are unfortunately a part of a custom service; they are not standardized, and the manufacturer did not provide any documentation.

当你连接到这个灯泡时,第一眼看到的是一个服务项清单。里面有一些是标准化的服务项,如设备信息(device Information)服务项和电池信息服务项;不过也有一些是自定义的服务项。我特别感兴趣的是那项 16 比特长的 UUID 的值为 0xff0f 的服务项。如果你点开这项服务项的话,你会看到一个长长的特性清单;这些特性的大部分我都不知道是什么,因为他们只有 UUID,而且更加遗憾的他们归属于自定义服务项;他们没有被标准化,厂商也没有提供任何文档。

The first characteristic with the UUID of 0xfffc seems particularly interesting. It has a value of four bytes. If we change the value of these bytes from 0x00000000 to 0x00ff0000, the light bulb turns red. Changing it to 0x0000ff00 turns the light bulb green, and 0x000000ff blue. These are RGB colors and correspond exactly to the hex colors we use in HTML and CSS.

第一个特性的 UUID 为 0xfffc,看起来特别有趣,它的值是4个字节,如果我们把这些字节从 0x00000000 改为 0x00ff0000,灯泡就会变红;改为 0x0000ff00 则会变绿,0x000000ff 变蓝。这些都是 RGB 颜色,刚好与我们在 HTML 和 CSS 中使用的十六进制的颜色对应。

What does that first byte do? Well, if we change the value to 0xff000000, the lightbulb turns white. The lightbulb contains four different LEDs, and by changing the value of each of the four bytes, we can create every single color we want.

那么第一个字节是用来干嘛的呢?嗯,如果我们把值改为 0xff000000,灯泡就会变白。灯泡里有四个不同的 LED,通过改变这四个字节的每个的值,我们就可以制作出我们想要的所有颜色。

WebBluetooth API

It is fantastic that we can use a native app to change the color of a light bulb, but how do we do this from the browser? It turns out that with the knowledge about Bluetooth and GATT we just learned, this is relatively simple thanks to the WebBluetooth API. It only takes a couple of lines of JavaScript to change the color of a light bulb.

用本地应用来改变灯泡的颜色是极其可行的,但如果是放在浏览器里面来做,我们该怎么做呢?刚刚我们已经学习了蓝牙和 GATT 相关的知识,借助于 WebBluetooth API 。只需要几行 JS 代码就可以改变灯泡的颜色。

Let’s go over the WebBluetooth API.

下面我们就来看下 WebBluetooth API。

连接到一个设备

The first thing we need to do is to connect from the browser to the device. We call the function navigator.bluetooth.requestDevice()and provide the function with a configuration object. That object contains information about which device we want to use and which services should be available to our API.

我们需要做的第一件事就是,在浏览器中与那台设备进行连接。调用函数 navigator.bluetooth.requestDevice() ,并传入一个配置对象,这个对象包含了关于我们想要使用的设备和服务的信息。

In the following example, we are filtering on the name of the device, as we only want to see devices that contain the prefix PLAYBULB in the name. We are also specifying 0xff0f as a service we want to use. Since the requestDevice() function returns a promise, we can await the result.

在下面这个例子中,我们基于设备的名字进行了筛选,因为我们只希望看到名字中包含了 PLAYBULB 前缀的设备;我们还用 0xff0f 来指定了我们想使用的服务项。由于 requestDevice() 函数返回的是一个 promise,所以我们可以 await 它的结果。

let device = await navigator.bluetooth.requestDevice({
    filters: [ 
        { namePrefix: 'PLAYBULB' } 
    ],
    optionalServices: [ 0xff0f ]
});
复制代码

When we call this function, a window pops up with the list of devices that conform to the filters we’ve specified. Now we have to select the device we want to connect to manually. That is an essential step for security and privacy and gives control to the user. The user decides whether the web app is allowed to connect, and of course, to which device it is allowed to connect. The web app cannot get a list of devices or connect without the user manually selecting a device.

当我们调用这个函数时,会弹出一个窗口,里面是一个满足我们所指定的过滤条件的设备清单。然后,我们必须从中选择我们想要连接的设备。这一步骤对于安全和隐私来说是不可或缺的,它把控制权交给了用户。用户决定了网页应用是否可以进行连接,当然,也决定了它所允许进行连接的是哪个设备。没有用户的手动选择,网页应用是不能获取到设备清单的,同样也是无法连接的。

the Chrome browser with the window that the user needs to use to connect to a device, with the lightbulb visible in the list of devices

After we get access to the device, we can connect to the GATT server by calling the connect() function on the gatt property of the device and await the result.

在我们获取到这台设备后,我让就可以通过调用这个设备的 gatt 属性上的 connect()` 函数来连接到 GATT 服务端上,并 await 它的结果。

let server = await device.gatt.connect();
复制代码

Once we have the server, we can call getPrimaryService() on the server with the UUID of the service we want to use as a parameter and await the result.

获得服务端后,我们就可以用我们想要使用的服务项的 UUID 作为参数来调用它的 getPrimaryService() ,并 await 其结果。

let service = await server.getPrimaryService(0xff0f);
复制代码

Then call getCharacteristic() on the service with the UUID of the characteristic as a parameter and again await the result.

然后再在服务项上用特性的 UUID 作为参数来调用 getCharacteristic() ,然后继续 await 其结果。

We now have our characteristics which we can use to write and read data:

然后得到了我们的特性之后,我们就可以用它来读写数据了:

let characteristic = await service.getCharacteristic(0xfffc);
复制代码

写入数据

To write data, we can call the function writeValue() on the characteristic with the value we want to write as an ArrayBuffer, which is a storage method for binary data. The reason we cannot use a regular array is that regular arrays can contain data of various types and can even have empty holes.

想要写入数据,我们可以把我们想要写入的值作为一个 ArrayBuffer 来在特性上调用 writeValue() 函数——ArrayBuffer 是一种二进制数据的存储方式。我们不使用常规数组的原因是数组可以包含任意类型的数据,而且甚至可能存在“空洞”。

Since we cannot create or modify an ArrayBuffer directly, we are using a ‘typed array’ instead. Every element of a typed array is always the same type, and it does not have any holes. In our case, we are going to use a Uint8Array, which is unsigned so it cannot contain any negative numbers; an integer, so it cannot contain fractions; and it is 8 bits and can contain only values from 0 to 255. In other words: an array of bytes.

由于我们不能直接创建和修改 ArrayBuffer,我们需要改用“typed array” (类型化数组) 来实现——Typed Array 中的所有元素都是相同的类型,也没有任何“空洞”。在我们的这个例子中,我们将使用的是 Unit8Array ,它是无符号的整型,所以不会包含任何负数和小数部分;同时他还是 8 比特长的,所以只能包含 0~255。换言之:它就是一个字节数组。

characteristic.writeValue(
    new Uint8Array([ 0, r, g, b  ])
);
复制代码

We already know how this particular light bulb works. We have to provide four bytes, one for each LED. Each byte has a value between 0 and 255, and in this case, we only want to use the red, green and blue LEDs, so we leave the white LED off, by using the value 0.

我们已经知道这个灯泡是如何工作的了。我们需要提供四个字节,对应到各个 LED。每个字节的值,范围为 0~255,在这个例子中,我们想要使用到的只有红、绿、蓝 LED ,所以我们通过使用 0 来保持白色 LED 关闭。

读取数据

To read the current color of the light bulb, we can use the readValue() function and await the result.

我们可以使用 readValue() 函数来读取灯泡当前的颜色,并 await 它的结果。

let value = await characteristic.readValue();
    
let r = value.getUint8(1); 
let g = value.getUint8(2);
let b = value.getUint8(3);
复制代码

The value we get back is a DataView of an ArrayBuffer, and it offers a way to get the data out of the ArrayBuffer. In our case, we can use the getUint8() function with an index as a parameter to pull out the individual bytes from the array.

我们取回来的值是一个 ArrayBuffer 的 DataView (数据视图),它提供了一种从 ArrayBuffer 取出数据的方式。在我们的例子中,我们可以通过将一个下标作为参数来使用 getUint8() 函数拉取单个字节。

监听变动

Finally, there is also a way to get notified when the value of a device changes. That isn’t really useful for a lightbulb, but for our heart rate monitor we have constantly changing values, and we don’t want to poll the current value manually every single second.

最后,还有一种方式是在设备的值发生变动了获得通知。对于灯泡来说,这个其实真的没什么用,但对于我们的心率监视器来说,它的值是持续不断变化的,我们不希望手动每秒来获取当前的值。

characteristic.addEventListener(
    'characteristicvaluechanged', e => {
        let r = e.target.value.getUint8(1); 
        let g = e.target.value.getUint8(2);
        let b = e.target.value.getUint8(3);
    }
);

characteristic.startNotifications();
复制代码

To get a callback whenever a value changes, we have to call the addEventListener() function on the characteristic with the parameter characteristicvaluechanged and a callback function. Whenever the value changes, the callback function will be called with an event object as a parameter, and we can get the data from the value property of the target of the event. And, finally extract the individual bytes again from the DataView of the ArrayBuffer.

要想在值发生变动时获得回调,我们需要在特性上调用 addEventListener() 函数——使用 characteristicvaluechanged 和一个回调函数作为参数。这样,在值发生变动时,回调函数就会被调用,并接受到一个 event 对象,我们可以从这个 event 的 target 属性的 value 属性上获得数据,然后再通过 ArrayBuffer 的 DataView 提取各个字节。

Because the bandwidth on the Bluetooth network is limited, we have to manually start this notification mechanism by calling startNotifications() on the characteristic. Otherwise, the network is going to be flooded by unnecessary data. Furthermore, because these devices typically use a battery, every single byte that we do not have to send will definitively improve the battery life of the device because the internal radio does not need to be turned on as often.

由于蓝牙网络的带宽有限,我们必须手动调用 startNotifications() 来启动通知机制;否则,网络中就会充斥着没必要的数据。然后,由于这些设备通常会用到一个电池,所以每节省一个没必要发送的字节,都可以提升设备的电池续航,因为没必要经常性地打开内部的射频信号。

总结

We’ve now gone over 90% of the WebBluetooth API. With just a few function calls and sending 4 bytes, you can create a web app that controls the colors of your light bulbs. If you add a few more lines, you can even control a toy car or fly a drone. With more and more Bluetooth devices making their way on to the market, the possibilities are endless.

我们已经对 WebBluetooth API 做了 90% 的讲解了。只需调用几个函数,发送4个字节,你就可以创建一个能控制你灯泡颜色的网页应用。如果再多写几行代码,你甚至可以控制一台玩具车或者飞起一台飞行器。随着越来越多的蓝牙设备不断地进入市场,未来将有无限的可能。

视频演示地址(需越墙):vimeo.com/303045191

(这个视频里演示了通过网页来控制彩灯、LED 面板、玩具车、飞行器等——译注)

扩展资源

关注下面的标签,发现更多相似文章
评论