摘要:通过渐进式 Web 应用(*Progressive Web Apps*)技术,你可以开发成熟的 Web 应用。 得益于大量新规范和新功能,以前需要在本机执行的应用,现在可以基于 Web 实现。 不过迄今为止,与硬件设备的交互仍然是遥不可及。 感谢 WebBluetooth 的出现,现在我们可以开发能够控制灯光、驾驶汽车甚至是无人机的 PWA。


通过PWA技术,Web 应用越来越和本机应用相差无几。同时 Web 应用也有其额外的好处,比如隐私和跨平台兼容性。

传统的 Web 应用与网络上服务器的通信机制非常出色。现在 Web 正在向本机应用靠拢,所以我们还需要与本机应用相同的功能。

过去几年在浏览器中实现的新规范和功能的数量是惊人的。我们已经有了处理3D的规范,例如 WebGL 和即将推出的 WebGPU。我们可以通过流式传输并生成音频,来观看视频并将网络摄像头用作输入设备。我们还可以使用WebAssembly 以几乎原生的速度运行代码。此外,尽管 web 最初只是一种网络媒体,但是它已经转向 service workers 的离线支持。

尽管这些功能非常强大,但是还有一个本机应用的专属领域:与设备进行通信。这是我们长期以来一直试图解决的问题,也是每个人可能会遇到的问题。Web非常适合与服务器通信,但不适合与设备通信。例如,在网络中设置路由器时,你可能需要输入 IP 地址并通过纯 HTTP 连接来使用 Web 界面,但是没有任何安全保护。这不管是从体验上还是在安全性上都是非常糟糕的。最重要的是,你怎样才能得到正确的IP地址呢?

当我们尝试创建一个试图与设备通信的 PWA 时,HTTP 协议是我们遇到的第一个问题。 PWA 只能只用 HTTPS 协议,而本地设备始终只使用 HTTP 。你还需要一个 HTTPS 证书,并且为了获得证书,还需要一个带有域名的公共服务器( 我正在谈论本地网络上无法访问的设备 )。

因此,对于许多设备来说,你需要使用本机应用来设置并使用它们,因为本机应用不受 Web 平台的限制,可以为其用户提供愉快的体验。但是我并不想下载一个 500 MB 的程序来做到这一点。也许你拥有的设备已经有几年了,应用程序从来没有为支持你的新手机做过更新。也许你想使用台式机或笔记本电脑,而制造商只提供一个移动应用。也不是一个理想的体验。

WebBluetooth 是一种新规范,已在 Chrome 和 Samsung Internet 中实现,它允许我们通过浏览器直接与Bluetooth Low Energy 设备进行通信。 PWA 通过与 WebBluetooth 相结合,可以提供 Web 应用的安全性和便利性,并具有直接与设备通信的能力。

由于通信范围有限,音频质量差和配对上存在的问题,蓝牙的名声比较差。但是,几乎所有这些问题都已成为过去式。 Bluetooth Low Energy 是一种现代规范,除了使用的无线频段相同外,它和旧的蓝牙规范几乎没有任何关系。每天有超过 1000 万台设备提供蓝牙支持,其中包括计算机和手机,还有各种设备,如心率和血糖监测仪,物联网设备,如灯泡和遥控汽车和无人机等玩具。

无聊的理论部分

由于蓝牙本身不是一种网络技术,它使用了一些我们可能不太熟悉的词汇。 先让我们看看蓝牙是如何工作的和一些涉及到的术语。

每个蓝牙设备都是“中央设备”(Central device)或“外围设备”( Peripheral )。 只有中央设备才能启动通信,并且只能与外围设备通信。 中央设备可以是计算机或移动电话。

外围设备无法启动通信,只能与中央设备通信。 此外,同一时间外围设备只能与一个中央设备通信。 外围设备无法与其他外围设备通信。

中央设备可以与多个外围设备通信

中央设备可以与多个外围设备通信

中央设备可以同时与多个外围设备通信,并且可以根据需要对消息进行中继。所以心率监测器无法与你的灯泡进行通信,但是你可以编写一个程序,该程序在接收心率的中央设备上运行,如果心率超过某个阈值就将灯变为红色。

当我们谈论 WebBluetooth 时,我们谈论的是蓝牙规范的一个特定部分,称为通用属性配置文件(Generic Attribute Profile),它的缩写是GATT。 (显然,GAP这个缩写已经被占用了。)

在 GATT 的支持下,我们不再谈论中央设备和外围设备,而是客户端和服务器。你的灯泡是服务器。这可能和你的直觉相违背,但是如果你仔细想想,实际上是有道理的。灯泡提供服务,即光。就像浏览器连接到互联网上的服务器一样,你的手机或计算机也是连接到灯泡中 GATT 服务器的客户端。

每个服务器都提供一个或多个服务。其中一些服务正式成为标准的一部分,但你也可以定义自己的服务。比如心率监测器的规范中就定义了官方服务。但是灯泡就不是这样,而且几乎每个制造商都在试图重新发明轮子。每项服务都有一个或多个特征。每个特征都有一个可以读取或写入的值。目前,最好将其视为一个对象数组,每个对象都具有值的属性。

简化的服务和特征层次结构

简化的服务和特征层次结构。

与对象属性不同,服务和特征不是由字符串标识的。 每个服务和特性都有一个唯一的UUID,长度为16 位或128位。严格的说,16 位 UUID 是为官方标准保留的,但几乎没有人遵循这一规则。 最后,每个值都是一个字节数组。 蓝牙中没有样式繁多的数据类型。

亲密接触蓝牙灯泡

让我们看一个实际的蓝牙设备:Mipow Playbulb Sphere。 你可以用 BLE Scanner 或 nRF Connect 等程序连接到设备并查看其所有服务和特征。 在这种情况下,我正在使用iOS的BLE扫描仪应用程序。

视频地址:https://player.vimeo.com/video/303046505

连接灯泡时首先看到的是服务列表。有一些标准化的服务,如设备信息服务和电池。但也有一些自定义服务。我对16 位 UUID 为 0xff0f 的服务特别感兴趣。如果你打开此服务,可以看到一长串特征值。我不知道这些特征是做什么用的,因为它们只是由 UUID 识别,而且不幸的是它们可能定制服务的一部分,它们并不是标准化的,制造商没有提供任何支持文档。

UUID 为 0xfffc 的第一个特性似乎特别有趣。它的值为四个字节。如果我们将这些字节的值从 0x00000000 改为 0x00ff0000 ,则灯泡变为红色。将其改为 0x0000ff00 会将灯泡变为绿色,修改为 0x000000ff 则变为蓝色。这些是RGB颜色,和 HTML 与 CSS 中使用的十六进制颜色完全对应。

第一个字节有什么作用?好吧,如果我们将值更改为 0xff000000 ,则灯泡会变成白色。灯泡包含四个不同的LED,通过更改这四个字节的值,我们可以创建想要的任何颜色。

WebBluetooth API

可以用原生应用来改变灯泡的颜色,这真是太棒了,但是我们怎样在浏览器中做到这一点呢?事实证明,凭借我们刚刚学到的关于蓝牙和 GATT 的知识,只需几行JavaScript就可以改变灯泡的颜色,这要归功 于WebBluetooth API。

我们来研究一下 WebBluetooth API。

连接到设备

我们要做的第一件事就是从浏览器连接到设备。可以调用函数 navigator.bluetooth.requestDevice() 并为函数提供配置对象,该对象含有关我们要使用哪个设备,以及都有哪些服务可用的信息。

在以下示例中,我们将过滤设备的名称,因为我们只想查看名称中包含前缀 PLAYBULB 的设备。我们还指定 0xff0f 作为我们想要使用的服务。由于 requestDevice() 函数返回一个promise,可以等待结果返回。

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

当我们调用此函数时,会弹出一个窗口,显示符合过滤规则的设备列表。 现在必须手动选择我们想要连接的设备。这是出于安全和隐私的需要,并为用户提供控制的权利。用户决定是否允许 Web 应用连接到设备,当然还有已经被允许连接的设备。 如果没有用户手动选择设备,Web 应用则无法获取设备列表或连接。

用户必须通过选择设备来手动连接。

*用户必须通过选择设备来手动连接*。

在我们访问设备之后,可以通过调用设备 gatt 属性上的 connect() 函数连接到 GATT 服务器并等待返回结果。

let server = await device.gatt.connect();

一旦我们连上服务器,就可以调用 getPrimaryService() 并传递服务的UUID,然后等待结果返回。

let service = await server.getPrimaryService(0xff0f);

然后使用特性的UUID作为参数调用服务上的 getCharacteristic() 并再次等待结果返回。

现在就得到了可用于读写数据的特性:

let characteristic = await service.getCharacteristic(0xfffc);

写数据

要写入数据,我们可以在特性上调用函数 writeValue() ,以 ArrayBuffer 的形式传递想要写入的值 ,这是二进制数据的存储方法。 我们不能使用常规数组的原因是常规数组中可以包含各种类型的数据,甚至可以存在空洞。

由于我们无法直接创建或修改 ArrayBuffer,因此应该使用“类型化数组”。 类型化数组种的每个元素总是相同的类型,并且没有任何漏洞。 在我们的例子中,将使用 Uint8Array,它是一个无符号的整数,因此不能包含任何负数,也它不能包含分数; 它是 8 位的,只能包含 0 到 255 之间的值。换句话说:这个是一个字节数组。

characteristic.writeValue(
    new Uint8Array([ 0, r, g, b  ])
);

我们已经知道这个特殊的灯泡是如何工作的。 必须提供四个字节,每个LED一个。 每个字节的值介于 0 到 255 之间,在这种情况下,我们只想使用红色,绿色和蓝色 LED,因此我们使用值 0 关闭白色LED。

读数据

要读取灯泡的当前颜色,可以使用 readValue() 函数并等待结果返回。

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

我们得到的值是 ArrayBuffer 形式的 DataView,它提供了一种从 ArrayBuffer 中获取数据的方法。 在我们的例子中,可以使用 getUint8() 并以索引作为参数来从数组中提取单个字节。

获得通知变更

最后,还有一种方法可以在设备值发生变化时收到通知。 这对于灯泡来说并不是很有用,但对于心率监测器来说,我们需要不断收到改变的值,而且并不希望每秒手动轮询这些值。

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();

要在值发生变化时及时获得回调,必须使用参数 characteristicvaluechanged 和回调函数调用特性上的 addEventListener() 函数。 每当值发生变化时,将使用事件对象作为参数调用回调函数,并且我们可以从事件目标的 value 属性中获取数据。 最后,再次从 ArrayBuffer 的 DataView 中提取单个字节。

由于蓝牙网络上的带宽有限,我们必须通过调用特性上的 startNotifications() 来手动启动这个通知机制。 否则,网络将被不必要的数据淹没。 此外,由于这些设备通常使用电池供电,因此没有必要的数据通信会影响设备的电池寿命,所以内置无线发射器不需要常开。

视频地址:https://player.vimeo.com/video/303045191

结论

本文已经覆盖了 WebBluetooth API 的90%。 只需调用几个函数并发送 4 个字节,你就可以创建一个控制灯泡颜色的 Web 应用。 如果再添加几行,你甚至可以控制玩具车或驾驶无人机。 随着越来越多的蓝牙设备进入市场,将产生无穷的可能性。

更多资源