# ESP32 项目实践

前面我们提到如何搭建开发环境用脚手架创建插件。接下来将以 ESP32 devTools 为例,为其创建一个可以点亮 LED 的插件。

# 创建插件

通过脚手架生成插件所需的基础目录和文件。

npx @ubtech/create-ucode-extension

根据提示,选择功能,按空格键选中或取消。本例中,使用:

  • ? 请输入插件的名字:esp32-ts
  • ? 要开启的硬件功能:
    • ❯◉ 串口协议 SerialPort
  • ? 要支持的开发功能
    • ❯◉ 使用 TypeScript

# 初始化插件代码

切换到插件代码根目录,执行以下命令,安装依赖

cd esp32-ts
yarn

# 编译插件代码

插件代码根目录,执行以下命令

yarn compile
#或
yarn compile:dev

插件代码编译完,dist 目录即目标插件目录,可以在 uCode 中导入该目录的方式导入插件。

image

修改插件,从哪里入手?

# 修改信息

# npm 包名

打开 esp32-ts/package.json,根据需要修改包名 nameversion

# 插件注册信息

打开 esp32-ts/static/manifest.json,根据需要修改。如versionsupportModesauthor等。

部分注册信息将在插件列表中显示

image

替换 esp32-ts/static/logo.svg 为你想要的图标

# 修改积木类

# 修改积木盒子分类名

ExampleDeviceExtension 中,getInfo方法返回的对象中,name 改为 My-ESP32

// block.ts
export class ExampleDeviceExtension {
  getInfo() {
    return {
      name: 'My-ESP32',
      // ...省略

重新编译代码,然后重新打开 uCode,导入插件,看到效果:

image

# 修改积木块

  • 移除默认积木块

脚手架默认提供了两个积木块作为示例。本例用不到,先将这两个积木块和对应的 func 删除。

// block.ts
export class ExampleDeviceExtension {
  getInfo() {
    return {
      name: "My-ESP32",
      blocks: [],
    };
  }
}
  • 如果我们要实现以下积木块,该怎么做:

    image

# 新增积木块

我们要实现的积木块由两个输入参数和文案组成。一个参数是输入框,另一个参数是下拉框(或弹窗)。

我们现在 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",
      },
    ];
    

    每个菜单子项对象包含textvalue字段。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 }) {}
    }
    

到此,基本完成了积木块的修改,可以尝试编译源代码,重新导入插件看看。

image

积木块怎么控制 ESP32 设备?我们接下来看下怎么定义通信类

# 协议&通信

# 准备硬件

在开始前,我们先来看下采用 ESP32 什么模式。我们用 micropython 的固件,和 micropython REPL 模式。

# 体验 REPL

下载一个串口调试助手,然后打开串口。如下图所示:

image

最后一行显示 >>>,表示 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 是否点亮了?如果是,恭喜你,很顺利。

image

如果要让积木块运行时,发送 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

这个文件中有一个connectionDemoSerialPortDevice,和串口参数配置spRegister

image

我们再打开 src/index.ts,可以看到spRegister赋值到registerDeviceRegister列表中了,并通过self.UCode.extensions.register(register);接口,进行了注册。

// index.ts
const register = {
  DeviceRegister: [spRegister, WebsocketRegister],
  BlockRegister: ExampleDeviceExtension,
};
self.UCode.extensions.register(register);

我们现在什么都不改,直接在 uCode 中看看。(如果在串口调试助手,或者其他软件中打开过 ESP32 串口,请先关闭。串口只能被一个软件进程操作。)

image

我们可以看到有两个连接方式:USB 连接、socket 连接。因为我们在DeviceRegister注册了这两个。

通过 USB 连接,也搜索到电脑上所有的串口设备。我连接了我的 ESP32 设备,然后在开发者工具中,看到 log,收到了设备的 REPL 启动后打印的信息,如>>>

image

# filter

我们修改下sp-device.tsspRegister,让搜索框只显示我们的 ESP32 设备。

先找出 ESP32 设备的 pid(productId)、vid(vendorId)参数。

  • Windows 设备管理器--端口(COM 和 LPT)中找到相应的 USB 设备,然后在详细信息中选“硬件 Id”可以看到 VID 和 PID 信息:

    image

  • MacOS 菜单--关于本机--系统报告中找出响应的 USB 设备:

    image

修改spRegisterfiltercustomDeviceNamecustomDeviceName是在搜索框中如何显示设备名。

// sp-device.ts
    filter: {
      vid: "10c4",
      pid: "ea60",
    },
    customDeviceName: (data) => `ESP32_${data?.comName}`,

重新编译代码,在 uCode 中看看效果。

image

那到现在为止,积木块类 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 灯闪烁。

image

我已经看到效果了,你呢?

image

现在,我们已经完成了发消息功能,如何实现接收和解析 ESP32 消息?

# 协议解析

串口传输数据时,可能会出现数据分包传输的情况,一条指令运行结果,可能会分几个包,发几次才完成传输。因此我们需要设计一下,使用某一字符作为分包符,碰到这个分包符时,将接收到的消息拼接起来,作为一条完整的消息。

image

本例暂无数据解析的需求,只是在esp32.tsonReceiveMsg中打印消息。这里收到的数据是 REPL 回显的、插件发给设备运行的程序。

image

协议你可以用 Google Protobuf、JSON、字符串、十六进制数等各种方式进行数据封装和压缩,分包符可以用换行符、特殊字符、协议头定义数据长度等方式实现。