编程的未来可能是大语言模型个人计算环境的结合。

前言

近期需要把 Scratch 的一些蓝牙(BLE)插件迁移到 Snap! 里.

思路

做这件事有两种方法。

  1. 尽可能在 JavaScript 做事情。复用原先 Scratch 插件的大部分 JavaScript 代码, 积木只在最后作为薄薄的一层,用于将 JavaScript API 暴露到用户界面。birdbraintech/MicrobitStarterProject 采用这种方式。
  2. 尽可能在积木里做事情。这是 Snap! 如此强大的主要原因之一。如果能够把 Web Bluetooth API 包装为合适的 primitives , 剩下的所有工作都能够在图形环境里完成, 以后引入新的 BLE 设备,就非常方便了。

第 1 种方案似乎能够快速完成短期迁移目标。 第 2 种方案似乎更看重长期,为以后的工作打下通用基础, 但似乎更耗时。

开始时,第 1 种方案对我有吸引力,我想快速做事情。但我发现 BLE 设备的 Scratch 插件,与 Scratch 框架有较多耦合,能够直接复用的部分似乎不多。如此一来,第二种方案就显得有吸引力了。由于在 Snap!中开发的效率高于 JavaScript, 所用的时间可能会更少。

由于准备迁移的 BLE 插件有不同类型, 我估计这两种方法都会用到。

需要注意的是一些非常"重"的项目,诸如使用了Scratch ble.js的项目(如toio), 第 1 种会省力很多。

对于未来的新设备,我偏好使用第二种方法接入。

如何构建合适的 primitives?

将 Web Bluetooth API 抽象为合适的 Snap! primitives 不是件容易的事儿,好在 Snap! 有一个绝佳的参考案例: 包装了 Web Serial APIprimitives。Snap! 中的MicroBlocks 库便是以此为基础来做的。

Web Bluetooth API 和 Web Serial API 非常相似,我们完全可以模仿后者 primitives 的设计方式。

如何调试?

调试常规的 JavaScript 相对容易,只需要在浏览器中工作即可。调试 Web Bluetooth API 和 Web Serial API 相关工作较难,它需要与外部设备通信。

这与调试 HTTP API 颇为相似。调试 HTTP API 时,我们经常需要弄清楚,客户端代码是否按照接口的要求,发送了正确的数据。达成目标的一种方法是构建一个 echo server, 它将我们发送消息原样返回,如一面镜子一样,让客户端知道自己做了什么。 httpbin 是这个领域的出色工具。

我想在调试 BLE 客户端(BLE 主机)时,拥有类似的东西,它能够原样返回我发出的数据。我需要构建一个 BLE echo server。有 2 个不错的方案可供选择:

  • 使用 MicroBlocks 对 esp32 编程制作做 BLE echo server
  • 使用Adafruit 开源的 ble_uart_echo_test.py 来做(ESP32 S3 和 micro:bit V2 应该都可以运行它)。

我更喜欢第一种方案,因为可以工作在 liveness 编程环境中。

开发

hello world

我打算先走通 BLE 的 hello world。

我将这里的 hello world 定义为: 在浏览器 JavaScript 代码中发送一个蓝牙消息,并且原样收到它。

BLE echo server

使用 MicroBlocks 对 esp32 编程, 构建一个 BLE echo server.

首先将 esp32-ble 固件(https://wwj718.github.io/post/img/esp32-ble-nus-20231202.bin) 刷入 ESP32 板子(我用的是 ESP32 DEVKIT V1, 你也可以在其他 ESP32 板子上使用)。

通过 MicroBlocks(1.2.44及以上版本) 的 显示高级积木 > 从 URL 安装 ESP 固件 来刷入固件。

运行 这个程序

测试它

运行起来之后,你就可以使用各种 BLE 客户端测试它了。从客户端发送的消息都会被原样返回。

如果使用苹果设备做实验, 如果之前连接过 esp32 板子的蓝牙, 请先忽略此设备,不然可能无法连上。

使用 NRF Connect 的测试结果:

使用 Chrome bluetooth-internals 的测试结果(似乎无法 read 消息):

当然你也可以使用其他你喜欢的蓝牙客户端(bleak, lightBlue…)测试它。

在浏览器中构建 BLE client

Web Bluetooth API 有很多样板代码, 我打算将脏活累活丢给了ChatGPT(GPT4), 这是我与ChatGPT的对话过程,中间只有一个小错误(大小写),我反馈错误之后它理解修复了。另有一个问题是,它没有考虑 html 的编码(<meta charset="UTF-8">), 这些都是小问题。

它相当出色地完成了一个可用的 BLE echo client, 而且带有简单UI.

在 Snap! 中编程

有了可用的 BLE echo client, 就可以思考如何在 Snap! 中实现这个用例了。

构建 BLE primitives

前边提到:

好在 Snap! 有一个绝佳的参考案例: 包装了 Web Serial APIprimitives。Snap! 中的MicroBlocks 库便是以此为基础来做的。
Web Bluetooth API 和 Web Serial API 非常相似,我们完全可以模仿后者 primitives 的设计方式。

我们已经有了BLE echo client, 它是一个真实 BLE 设备(BLE echo server)的客户端。我们未来将在 Snap! 里固件的 BLE 设备库,正是这样功能的东西,所以可以把 BLE echo client 视为第一个实现目标,它足够简单,但足够典型,这是再好不过的原型项目了。BLE primitives 是否提供了合适粒度的抽象, 在制作 BLE echo client 的过程中,由于依赖于 BLE primitives ,我们也能评估它。

学习 Snap! MicroBlocks 库

由于 Snap! MicroBlocks 库 构建在 web serial primitives 之上,通过阅读 MicroBlocks 库的实现,我们能够学到极其有价值的东西。

继续进入下一层积木,我们将抵达 web serial primitives, 这是积木的最底层:

如果希望继续往下,我们就需要进入 primitives 的实现层, Snap! 目前的实现语言是 JavaScript(未来似乎可以实现在WebAssembly里)

前边提到:

Web Bluetooth API 和 Web Serial API 非常相似,我们完全可以模仿后者 primitives 的设计方式。

既然我们知道这两种 API 非常非常相似,又有了 Snap! 中的 web serial primitives,似乎就可以要求 ChatGPT 来做这个模仿工作, 处理 API 的细节差异问题,应该在它的能力范围之内。

让 ChatGPT 工作之前,我们先来配置下开发环境,以便于测试和调整 ChatGPT 给出的代码。

开发方式

Snap! 代码只是一堆的静态文件

开发它只需要编写普通 JavaScript 即可,无须任何打包工具。

你可以直接打开 snap.html 文件测试你正在开发的代码。更推荐的方式是在项目根目录运行一个静态服务器: python -m http.server, 然后打开 http://127.0.0.1:8000/。 相比于直接打开 snap.html 能够获得更完整的功能: 插件和精灵图片、音乐都可使用。

一般而言,要访问硬件(serial, 蓝牙, 摄像头, 麦克风…), 浏览器会要求页面需要部署为https。但对于本地开发环境(127.0.0.1), 无须 https, 也可访问这些 API。

如果你在本地开发 CodeLab Adapter 插件,则需要将页面部署为 https

让 ChatGPT 构建 BLE primitives

ChatGPT 是绝佳的实习生,它细心,耐心,拥有丰富的知识,并乐于听取反馈。 我喜欢让它来编写具体的代码。

万事开头难。 我想可能降低开始的工作难度, 我打算一开始只实现连接设备写入数据这两个接口。 由于 MicroBlocks 提供了出色的实时编程界面,在我们可以在那儿看到客户端写入了什么数据,这避免了我们一开始需要在客户端中实现读取服务端返回数据的功能。

ChatGPT 并没有给出一次可用的代码, 但完全符合我的预期。我期待它帮助我编写 API 相关的代码, 这里有许多细节和样板代码,它做得很好。

我并不假设它对 Snap! 有充分了解, 我也不打算在上下文中把 Snap! 相关的知识都抛给它, 那会让对话失去重点,我想要的是 Copilot , 我在边上监督和检验。

我调整了两处涉及 JavaScript 与 Snap! 的数据交换接口:

  • bt_connect(options): ChatGPT 以为可以从 Snap! 传递 object 给 js ,这个猜测是完全合理的。但 Snap! 使用内部的数据结构来传递(定制过的list)。
  • acc.result = {device, server}: Snap! 一般使用自定义的 List 来传递多个数据, 更好的做法是: acc.result = new List([device, server])

此外, 大多数代码都是可用的,对于原型制作,非常令人满意,这远比我自己编程来得高效。

给蓝牙设备发送数据:

由于我工作在个人计算风格的 liveness 环境中,我可以实时地理解和修复系统。

之后我又让 ChatGPT 帮我完成了读取数据断开连接 primitives:

获取 BLE 设备数据的 2 种方式

有了这 4 个模仿 serial primitives 的 BLE primitives, 我们确实可以构建出类似 MicroBlocks 的库:

但需要注意, 这是一种拉(pull)模式: 蓝牙客户端主动发起读取(READ)操作。

获取 BLE 设备信息的更典型方式是推(push)模式: 蓝牙设备根据特性值的变化自动推送(NOTIFY)。

(与 ChatGPT 的这段对话, 让我意识到上述区分)

我们之前在 Scratch 中使用的大多数 BLE 插件都是推(push)模式(NOTIFY):

birdbraintech/MicrobitStarterProject 也是这种风格:

围绕 NOTIFY 构建 primitive

记住我们之前的偏好:

尽可能在积木里做事情

往这个目标前进的一种方式是构建尽可能通用的 primitive。

我们想要这样一个 primitive: 它能够获取 BLE 设备中 Characteristic NOTIFY 的消息,并进一步分发它。理想的方式是直接分发给其他 reporter 积木。 退而求其次, 存储到某个js容器里(Scratch中基本是这样做的), reporter 积木根据这些容器里的数据,报告自身状态。

我在 Snap! USB micro:bit 插件里展示了一种 “尽可能在积木里做事情” 的技巧:

你可以将一个积木"闭包"作为回调函数抛到 primitive(js函数) 里, primitive(只是js函数) 可以决定何时调用这些积木,并且可以给它传递参数。

这个例子也展示了,甚至可以在回调函数中动态修改积木(运行中修改积木可能不是最佳实践,不推荐使用)。

我们让 ChatGPT 帮我们添加一个 bt_notify primitive(它一次就做对了!),然后我们调整成 callback 风格。再让 ChatGPT 写一个 bt_connected primitive. 我们基本得到令人满意的所有 BLE primitives 了! 再做个收尾工作,将所有的 bt_ 改为 ble_

BLE echo client插件

有了上边的 primitives, 要构建我们的测试用例(BLE echo client插件)是轻而易举的。

值得的注意的是,我们构建了一个容器变量(echo_notify_datas), 用来存放 notify 的数据,在 Scratch 中,开发者通常在 js 里将 notify 数据映射为 reporter 积木。由于 Snap! 有强大的 list 积木,数据映射到积木的工作要比在 js 里容易得多,尤其是可以动态实时调试!

当然可能还需要修剪一下粗糙的边缘,诸如多次 connect 会怎样 ? 这些边缘问题通常与状态管理有关,由于我们在 Snap 拥有所有的能力,而且是完全活性的,所以这些问题比起在 js 里处理要轻松得多。


附录

Nordic UART Service (NUS)

前边说到:

Web Bluetooth API 和 Web Serial API 非常相似,我们完全可以模仿后者 primitives 的设计方式。

这个类比是有问题的,更确切的表述是 Nordic UART Service (NUS) 与 Web Serial API 非常相似。

在与ChatGPT 的这段对话,它提到:

BLE UART 服务(Bluetooth Low Energy Universal Asynchronous Receiver/Transmitter Service)是一种在蓝牙低功耗(BLE)技术上实现的串行通信服务。这种服务模拟了传统的UART通信 UART是实现串行(Serial)通信的一种技术或设备。当我们谈论串行通信时,特别是在微控制器和计算机之间,我们通常是指使用UART的通信方式。

由于本文的 BLE 测试设备,是一个 BLE echo server(运行着Nordic UART Service), 所以我们学习 serial primitives 是合适的。

但需要注意, Nordic UART Service 只是使用 BLE 构建服务的一种方式, 当然它是一种强大的方式,但很多蓝牙设备并不一定提供 Nordic UART Service。由于 BLE API 主要围绕对 Characteristic 进行 read/write/notify 来操作而构建, 所以我们以Nordic UART Service 为用例而构建的 primitives 基本是通用的。

对于任何自定义的 BLE 设备,我推荐使用 Nordic UART Service,因为它足够简单和通用。它使得与蓝牙设备的通信, 与串口或websockets 相似。

使用 bytes 而不是 utf8 文本

为了提高 primitives 的通用性, 与蓝牙通信的积木 (read/notify/write) 都直接操作 bytes 数据(在Snap! 中表示为列表(元素取值范围 0-255))。 这样一来, 对 BLE 设备的任何操作都完全可以在 Snap! 中实现了, 不再需要进入下一层。

这部分的具体实现没有在本文中体现。

设备过滤

由于设备过滤有多种可能性, 我们允许用户直接传递 JSON 字符串,来指定过滤规则,具体规则与 JavaScript 中的 API 一致:

Chrome 调试工具

迁移 Scratch 的社区乐高插件遇到一些困难。

这些乐高插件试图实现通用的乐高蓝牙协议, 同时代码压缩在单一文件里, 导致阅读困难。

Scratch 插件系统设计得非常糟糕, 臃肿不堪。 相比于阅读弄清楚代码,更简单的方式是观察积木运行时,到底发出了什么蓝牙信号。

使用 Chrome 调试工具可以很容易做到这点,策略是: 打断点。

找到积木相关的代码, 打上断点, 然后跟踪代码的运行, 在 console 中打印出发送的蓝牙数据。

单步运行, 直到进入这里:

这里的 command 就是实际发给蓝牙设备的数据(this.send 没有处理数据)。

参考