# ESP32 项目实践
前面我们提到如何搭建开发环境,用脚手架创建插件。接下来将以 ESP32 devTools 为例,为其创建一个可以点亮 LED 的插件。
# 创建插件
通过脚手架
生成插件所需的基础目录和文件。
npx @ubtech/create-ucode-extension
根据提示,选择功能,按空格键选中或取消。本例中,使用:
- ? 请输入插件的名字:esp32-ts
- ? 要开启的硬件功能:
- ❯◉ 串口协议 SerialPort
- ? 要支持的开发功能
- ❯◉ 使用 TypeScript
# 初始化插件代码
切换到插件代码根目录,执行以下命令,安装依赖
cd esp32-ts
yarn
# 编译插件代码
插件代码根目录,执行以下命令
yarn compile
#或
yarn compile:dev
插件代码编译完,dist
目录即目标插件目录,可以在 uCode 中导入该目录的方式导入插件。
修改插件,从哪里入手?
# 修改信息
# npm 包名
打开 esp32-ts/package.json
,根据需要修改包名 name
,version
# 插件注册信息
打开 esp32-ts/static/manifest.json
,根据需要修改。如version
、supportModes
、author
等。
部分注册信息将在插件列表中显示
# 插件 logo
替换 esp32-ts/static/logo.svg
为你想要的图标
# 修改积木类
# 修改积木盒子分类名
将 ExampleDeviceExtension
中,getInfo
方法返回的对象中,name 改为 My-ESP32
// block.ts
export class ExampleDeviceExtension {
getInfo() {
return {
name: 'My-ESP32',
// ...省略
重新编译代码,然后重新打开 uCode,导入插件,看到效果:
# 修改积木块
- 移除默认积木块
脚手架默认提供了两个积木块作为示例。本例用不到,先将这两个积木块和对应的 func 删除。
// block.ts
export class ExampleDeviceExtension {
getInfo() {
return {
name: "My-ESP32",
blocks: [],
};
}
}
如果我们要实现以下积木块,该怎么做:
# 新增积木块
我们要实现的积木块由两个输入参数和文案组成。一个参数是输入框,另一个参数是下拉框(或弹窗)。
我们现在 getInfo
方法返回值的 blocks
中,添加积木块定义:
// block.ts
blocks: [
{
opcode: "setPinValue",
func: "setPinValue",
blockType: self.UCode.BlockType.COMMAND,
text: "设置引脚 [PIN] 为 [VALUE] 状态",
arguments: {
PIN: {
type: self.UCode.ArgumentType.NUMBER,
defaultValue: 2,
},
VALUE: {
type: self.UCode.ArgumentType.DROPDOWN_MENU,
menu: "pinValueMenus",
defaultValue: '1',
},
},
},
],
这里的
func
是积木块在线模式
执行函数的名字,在ExampleDeviceExtension
类中,有一个函数名和func
字段值一致。这里的
opcode
是积木块的 id,也可以和积木块在线运行函数名字一致。不同的
opcode
积木块,可以使用相同的func
。如// 积木块A { opcode: 'A', func: 'A' }, // 积木块B { opcode: 'B', func: 'A' },
这里使用的积木块类型是:
blockType: self.UCode.BlockType.COMMAND
,普通的执行积木块,上面和下面都可以连接其他积木块。设置的积木块文本是:
text: "设置引脚 [PIN] 为 [VALUE] 状态"
,其中用两个中括号字段[PIN]
和[VALUE]
来表示积木块的 Field(参数)的 key 的声明。参数的定义在
arguments
中。在arguments
对象中,key 和text
中声明的字段一致。在参数
PIN
中,我们用了NUMBER
类型来定义数据类型,也提供了默认值2
// block.ts PIN: { type: self.UCode.ArgumentType.NUMBER, defaultValue: 2, }
在参数
VALUE
中,我们用了DROPDOWN_MENU
类型来定义数据类型(也可以用 STRING、NUMBER 来定义)。跟PIN
不同的是,多了个menu: "pinValueMenus"
字段。这个字段表示积木块用了名叫pinValueMenus
的下拉菜单。// block.ts VALUE: { type: self.UCode.ArgumentType.DROPDOWN_MENU, // 下拉菜单 menu: "pinValueMenus", defaultValue: '1', // 默认值 }
定义 menus
上面需要用到
menu: "pinValueMenus"
,我们得找个地方来定义,统一管理所有menu
,方便后期维护。在 src 目录下,建一个文件
menus.ts
,里面定义了菜单变量,类型通常是一个数组。// menus.ts export const pinValueMenus = [ { text: "高电平", value: "1", }, { text: "低电平", value: "0", }, ];
每个菜单子项对象包含
text
、value
字段。text 是菜单子项的文案,可以设置国际化;value 是子项的值。value 一般用于逻辑代码中。回到 block.ts 中,导入 menus.ts 文件。然后在
getInfo
函数中,使用 menus// block.ts import { pinValueMenus } from './menus'; // ...省略 getInfo() { return { // ...省略 menus: { // key为积木块中声明的菜单名 pinValueMenus: { acceptReporters: false, // 是否可以嵌入其他积木块,false为否 items: pinValueMenus, // 具体的菜单子项列表,从menus.ts文件中导入。 }, } } }
定义 func 在
ExampleDeviceExtension
类中,创建一个函数,名字和上面积木块 func 字段值一致。我们先建一个空函数。// block.ts export class ExampleDeviceExtension { //... setPinValue(args: { [key: string]: any }, util: { targetId: string }) {} }
到此,基本完成了积木块的修改,可以尝试编译源代码,重新导入插件看看。
积木块怎么控制 ESP32 设备?我们接下来看下怎么定义通信类
# 协议&通信
# 准备硬件
在开始前,我们先来看下采用 ESP32 什么模式。我们用 micropython 的固件,和 micropython REPL 模式。
- micropython 官网:https://docs.micropython.org/en/latest/ (opens new window)
- ESP32 固件下载:esp32-20210902-v1.17.bin (opens new window)
- 刷机教程:https://docs.micropython.org (opens new window)
# 体验 REPL
下载一个串口调试助手
,然后打开串口。如下图所示:
最后一行显示 >>>
,表示 REPL 处于空闲状态。我们可以输入代码了。
从 micropython 官网找到控制 LED 的代码:
https://docs.micropython.org/en/latest/esp32/quickref.html#pins-and-gpio (opens new window)
# 我们要控制的是PIN2,它连接了板载LED灯。点亮可以用on(),也可以用value(1)
from machine import Pin;
p2 = Pin(2, Pin.OUT);
p2.value(1);
将上面代码拷贝到串口调试助手
发送区,发送看看效果。板子上的蓝色 LED 是否点亮了?如果是,恭喜你,很顺利。
如果要让积木块运行时,发送 micropython 代码给 ESP32 设备运行,实现积木块控制硬件的功能,我们还需要完成通信、协议、API 等相关工作。
# 设备类
为了方便把 micropython 代码封装起来,和 block 对应上,并且为了模拟 ESP32 设备,我们先创建一个 ESP32 的设备类。
这个类将会负责:
- 封装积木块接口
- 生成 micropython 代码片段
- 与硬件通信类结合,收发消息
在 src 目录下,创建 esp32.ts
文件。
// esp32.ts
class ESP32 {}
export default ESP32;
# 可配置代码片段
封装一个可配置的代码片段生成函数,和一个随机变量名函数。这两个函数在后面高频使用。
// esp32.ts
/**
* 生成代码片段,可配置import
* @param imports import列表
* @returns
*/
_createScript(imports = ['machine']) {
return (script: string) => {
return `import ${imports.join(',')};${script}\r\n`;
};
}
/**
* 生成随机字符串
*/
_randomVarName(prefix: string) {
return prefix + Math.random().toString(36).substring(2, 6);
}
# 操作引脚电平的接口
封装一个可以设置引脚电平的函数。
// esp32.ts
setPinValue(pin: number, value: number) {
const varName = this._randomVarName('pin_');
const message = this._createScript()(
`${varName}=machine.Pin(${pin}, machine.Pin.OUT);${varName}.value(${value});`,
);
}
也可以不使用 _createScript
函数,直接写成:
// esp32.ts
setPinValue(pin: number, value: number) {
const message = `from machine import Pin;p${pin}=Pin(${pin}, Pin.OUT);p${pin}.value(${value});`
}
到这里我们暂时不处理这个message
变量,后面会使用send
函数,将数据发给设备。
我们已经定义了一个接口,也能生成 micropython 片段,但怎么发给设备了?
# 硬件连接类
# register
在 src/devices
目录中,有个 sp-device.ts
文件。这个是脚手架帮我们创建的,因为我们在脚手架安装过程中,选择了 串口协议 SerialPort
。
这个文件中有一个connection
类DemoSerialPortDevice
,和串口参数配置spRegister
。
我们再打开 src/index.ts
,可以看到spRegister
赋值到register
的DeviceRegister
列表中了,并通过self.UCode.extensions.register(register);
接口,进行了注册。
// index.ts
const register = {
DeviceRegister: [spRegister, WebsocketRegister],
BlockRegister: ExampleDeviceExtension,
};
self.UCode.extensions.register(register);
我们现在什么都不改,直接在 uCode 中看看。(如果在串口调试助手,或者其他软件中打开过 ESP32 串口,请先关闭。串口只能被一个软件进程操作。)
我们可以看到有两个连接方式:USB 连接、socket 连接。因为我们在DeviceRegister
注册了这两个。
通过 USB 连接,也搜索到电脑上所有的串口设备。我连接了我的 ESP32 设备,然后在开发者工具中,看到 log,收到了设备的 REPL 启动后打印的信息,如>>>
。
# filter
我们修改下sp-device.ts
的spRegister
,让搜索框只显示我们的 ESP32 设备。
先找出 ESP32 设备的 pid(productId)、vid(vendorId)参数。
Windows 设备管理器--端口(COM 和 LPT)中找到相应的 USB 设备,然后在详细信息中选“硬件 Id”可以看到 VID 和 PID 信息:
MacOS
菜单--关于本机--系统报告
中找出响应的 USB 设备:
修改spRegister
的filter
和customDeviceName
。customDeviceName
是在搜索框中如何显示设备名。
// sp-device.ts
filter: {
vid: "10c4",
pid: "ea60",
},
customDeviceName: (data) => `ESP32_${data?.comName}`,
重新编译代码,在 uCode 中看看效果。
那到现在为止,积木块类 block.ts、设备类 esp32.ts、通信类 sp-device.ts 都有了,怎么把这三个模块串起来,实现积木块发消息给 ESP32 设备呢?
# 收发消息
在 src/devices/sp-device.ts
中,sendMsg
向串口发送消息,onData
设置接收消息的监听器。
在本例中,DemoSerialPortDevice
只作为设备连接、收发消息的类,它的生命周期也是 uCode 插件核心框架管理的。
我们在插件中如何获取DemoSerialPortDevice
的实例对象?
通过上下文函数getDevice
,传入当前插件对应的角色 Id(targetId),获取的对象即是DemoSerialPortDevice
的实例对象。
const device = self.UCode.extensions.getDevice(targetId);
回到 src/esp32.ts
设备类中,我们建一个单例,并从外部注入 targetId。这个 targetId 可以在 block.ts 中获取。
// esp32.ts
class ESP32 {
private static mInstance: ESP32;
private constructor(private targetId: string) {}
static getInstance(targetId: string) {
if (!this.mInstance) {
this.mInstance = new ESP32(targetId);
}
return this.mInstance;
}
// ...省略
}
然后添加获取DemoSerialPortDevice
的实例对象的函数、发送/接收消息的函数。
// esp32.ts
getDevice(needToast = true) {
// eslint-disable-next-line no-undef
const device = self.UCode.extensions.getDevice(this.targetId) as DemoSerialPortDevice;
if (!device?.isConnected() && needToast) {
Toast("您还没连接ESP32设备!");
return undefined;
}
device?.onData(this.onReceiveMsg.bind(this));
return device;
}
isConnected(needToast = true) {
return this.getDevice(needToast)?.isConnected();
}
onReceiveMsg(data: string | Buffer) {
console.log("received from esp32 :", data);
}
send(message: string, timeout = 3000) {
return new Promise((resolve, reject) => {
// 获取通信设备对象
const device = this.getDevice();
// 监听消息
const { dispose } = device?.onData((data) => {
if (timeoutDispose) {
clearTimeout(timeoutDispose);
}
// 注销监听器
dispose();
resolve(data);
});
// 设置通信超时
const timeoutDispose = setTimeout(() => {
dispose?.();
reject(new Error("timeout"));
}, timeout);
// 发送消息
device?.sendMsg(message);
});
}
在send
函数中,也调用了onData
接收消息,目的是为了一发一收,发送消息后,等待响应,可以实现积木阻塞块的功能。同时还添加了 timeout 来处理发送后无响应的情况。
我们完善一下setPinValue
函数,里面调用send
将消息发给 ESP32。
// esp32.ts
setPinValue(pin: number, value: number) {
const varName = this._randomVarName("pin_");
const message = this._createScript()(
`${varName}=machine.Pin(${pin}, machine.Pin.OUT);${varName}.value(${value});`
);
return this.send(message);
}
# 积木块调用设备接口
上面我们已经把setPinValue
接口写完了。现在在积木块执行函数中看看怎么调用。
积木块执行函数的第一个参数是积木块输入控件的数据列表,key 名和积木块定义时,参数的 key 名一致。第二个参数util
中包含当前角色 ID targetId。
// block.ts
setPinValue(args: { [key: string]: any }, util: { targetId: string }) {
}
我们先导入 ESP32 设备类
// block.ts
import ESP32 from "./esp32";
在积木块执行函数中,通过targetId
拿到当前设备对象,然后判断设备是否已连接,连接后可通信。未连接会有 Toast 提示。
// block.ts
setPinValue(args: { [key: string]: any }, util: { targetId: string }) {
const device = ESP32.getInstance(util.targetId);
if (device.isConnected()) {
device.setPinValue(args.PIN, args.VALUE);
}
}
args 参数类型可能是 string,也可能是 number。我们可以显示转换,确保数据类型正确性。
从 SDK 中导入类型转换工具Cast
:
// block.ts
import { CommonUtility } from "@ubtech/ucode-extension-common-sdk";
const { Cast } = CommonUtility;
修改setPinValue
:
// block.ts
setPinValue(args: { [key: string]: any }, util: { targetId: string }) {
const device = ESP32.getInstance(util.targetId);
if (device.isConnected()) {
const pin = Cast.toNumber(args.PIN);
const value = Cast.toNumber(args.VALUE);
device.setPinValue(pin, value);
}
}
关于积木块执行函数的返回值类型以及效果,可以在开放平台文档中阅读详细内容。
我们运行下面的程序,让 LED 灯闪烁。
我已经看到效果了,你呢?
现在,我们已经完成了
发消息
功能,如何实现接收和解析 ESP32 消息?
# 协议解析
串口传输数据时,可能会出现数据分包传输的情况,一条指令运行结果,可能会分几个包,发几次才完成传输。因此我们需要设计一下,使用某一字符作为分包符
,碰到这个分包符时,将接收到的消息拼接起来,作为一条完整的消息。
本例暂无数据解析的需求,只是在esp32.ts
的onReceiveMsg
中打印消息。这里收到的数据是 REPL 回显的、插件发给设备运行的程序。
协议你可以用 Google Protobuf、JSON、字符串、十六进制数等各种方式进行数据封装和压缩,分包符可以用换行符、特殊字符、协议头定义数据长度等方式实现。