BearPi-HM Nano 2 实战:用 HarmonyOS 手机控制人体检测蜂鸣器

最近用 BearPi-HM Nano 2 和 E53_IS1 人体红外扩展板做了一个小实验:开发板检测到有人靠近时自动响蜂鸣器,同时可以在 HarmonyOS 手机上查看状态、手动鸣响和停止鸣响。

一开始功能看起来很简单,真正联调时却遇到了几个很典型的问题:HTTP 服务只能访问一次、手机换网后仍显示在线、蜂鸣器第一次能响但停止后第二次再也响不起来。本文把最终可用的实现思路和排查过程记录下来,希望能给做 WS63、OpenHarmony 和 ArkTS 联调的同学一些参考。

最终实现效果

这套方案完全运行在局域网内,不依赖公网服务器。

开发板连接 2.4 GHz Wi-Fi,通过 DHCP 获取 IP,然后监听 8888 端口。HarmonyOS 手机和开发板处于同一局域网时,可以完成下面这些操作:

  • 查看开发板是否在线
  • 查看人体检测状态
  • 查看蜂鸣器是否正在鸣响
  • 手动开启蜂鸣器
  • 停止当前鸣响
  • 开启或关闭人体自动报警
  • 连续执行多次“鸣响、停止、再次鸣响”

手机与开发板不在同一局域网,或者当前 IP 无法访问时,应用会及时显示连接失败,不会继续使用上一次请求留下的在线状态。

演示视频可以按照下面的顺序录制:先展示串口 IP,再打开手机应用连接设备,连续执行两次鸣响和停止,最后切换手机网络,展示应用从在线变为离线。

整体通信流程

开发板内部主要运行两个任务。

AlarmTask 负责 E53_IS1 人体红外检测。GPIO10 检测到上升沿后只发送一个事件,真正的蜂鸣器控制放在线程中执行,避免在中断回调里执行耗时操作。

NetworkTask 负责连接 Wi-Fi、获取 IP,并启动 HTTP Server。手机发送控制指令后,开发板更新业务状态、控制 GPIO11/PWM3,然后返回 JSON。

当前使用的接口比较简单:

  • GET /api/status:查询完整状态
  • GET /api/manual/on:手动鸣响
  • GET /api/silence:停止当前鸣响
  • GET /api/alarm/enable:开启人体自动报警
  • GET /api/alarm/disable:关闭人体自动报警

状态接口返回的内容如下:

{
  "ok": true,
  "alarmEnabled": true,
  "manualOn": false,
  "motionDetected": false,
  "buzzerOn": false
}

这里需要区分“停止鸣响”和“关闭自动报警”。停止鸣响只结束当前声音,不能断开 Wi-Fi,也不能关闭 HTTP Server;关闭自动报警才表示以后检测到人体时不再自动响。

开发板端的关键实现

Wi-Fi 账号和密码在本地配置,实际项目中不要把真实密码提交到公开仓库。

#define WIFI_SSID     "你的2.4G Wi-Fi名称"
#define WIFI_PASSWORD "你的Wi-Fi密码"
#define HTTP_SERVER_PORT 8888

Wi-Fi 连接成功后要输出 DHCP 分配的 IP:

printf("[WIFI] network ready\r\n");
WifiPrintIpAddress();

开发板复位或路由器重新分配租约后,IP 可能变化,所以手机端要以串口最新输出为准。为了减少每次修改 IP 的麻烦,可以在路由器后台根据开发板 MAC 地址设置 DHCP 地址保留。

HTTP Server 创建成功后,监听 socket 要一直保留。每次手机请求只关闭本次连接产生的 clientFd:

while (1) {
    clientFd = lwip_accept(
        serverFd,
        (struct sockaddr *)&clientAddress,
        &clientAddressLength
    );

    if (clientFd < 0) {
        osDelay(10);
        continue;
    }

    HandleHttpClient(clientFd);
    lwip_close(clientFd);
}

这里使用 lwip_socket、lwip_accept、lwip_recv、lwip_send 和 lwip_close,避免混用不同层的 socket 接口。

收到控制请求后的路由处理如下:

if (strstr(request, "GET /api/manual/on ") != NULL) {
    ManualBuzzerOn();
    SendStatusResponse(clientFd);
    return;
}

if (strstr(request, "GET /api/silence ") != NULL) {
    SilenceBuzzer();
    SendStatusResponse(clientFd);
    return;
}

手动鸣响必须真正调用一次硬件控制,不能只修改软件变量:

static void ManualBuzzerOn(void)
{
    osMutexAcquire(g_stateMutex, osWaitForever);

    g_manualOn = true;
    g_silenced = false;
    SetBuzzerOutputLocked(true);

    osMutexRelease(g_stateMutex);
}

停止鸣响则同步清除手动状态和蜂鸣器状态:

static void SilenceBuzzer(void)
{
    osMutexAcquire(g_stateMutex, osWaitForever);

    g_manualOn = false;
    g_silenced = true;
    SetBuzzerOutputLocked(false);

    osMutexRelease(g_stateMutex);
}

蜂鸣器只能响一次的问题

这是整个联调过程中最值得记录的坑。

最初代码使用 IoTPwmStart 开启 PWM,使用 IoTPwmStop 停止 PWM。第一次完全正常,但停止以后再次调用 IoTPwmStart,串口返回:

[PWM] start result=4294967295

4294967295 是无符号形式的 -1,表示底层启动失败。HTTP 接口仍然可以返回成功,所以手机会显示“指令执行成功”,但蜂鸣器并没有真正发声。

最终没有继续反复 Stop 和 Start,而是让 PWM 在运行周期内只启动一次。需要静音时,把 GPIO11 从 PWM3 切换成普通 GPIO,并输出低电平;需要再次鸣响时,再把 GPIO11 切回 PWM3。

静音的关键代码:

static void MuteBuzzerByGpio(void)
{
    IoTGpioSetFunc(
        E53_IS1_BEEP_GPIO,
        IOT_GPIO_FUNC_GPIO_11_GPIO
    );

    IoTGpioSetDir(
        E53_IS1_BEEP_GPIO,
        IOT_GPIO_DIR_OUT
    );

    IoTGpioSetOutputVal(
        E53_IS1_BEEP_GPIO,
        IOT_GPIO_VALUE0
    );
}

再次鸣响时只恢复引脚复用:

IoTGpioSetFunc(
    E53_IS1_BEEP_GPIO,
    IOT_GPIO_FUNC_GPIO_11_PWM3_OUT
);

if (!g_pwmStarted) {
    result = IoTPwmStart(
        PWM_PORT_PWM3,
        PWM_DUTY,
        PWM_FREQ
    );

    if (result == IOT_SUCCESS) {
        g_pwmStarted = true;
    }
}

这样第一次鸣响会真正启动 PWM,后续停止只断开 PWM 与蜂鸣器引脚的输出关系,再次鸣响只恢复 GPIO11 的 PWM3 复用,因此不会再次触发底层 PWM 启动失败。

这种做法会让 PWM 外设在后台保持运行,不过静音期间 GPIO11 输出低电平,蜂鸣器不会发声。对于当前局域网控制和功能验证场景,这个方案比较稳定。

HarmonyOS 手机端实现

手机端使用 ArkTS 和 Network Kit,module.json5 中需要申请网络权限:

"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET"
  }
]

每次请求都创建独立的 HttpRequest,请求结束后再销毁:

private async requestDevice(path: string): Promise<DeviceStatus> {
  const request: http.HttpRequest = http.createHttp();

  try {
    const response: http.HttpResponse = await request.request(
      `${this.getBaseUrl()}${path}`,
      {
        method: http.RequestMethod.GET,
        expectDataType: http.HttpDataType.STRING,
        usingCache: false,
        connectTimeout: 1500,
        readTimeout: 1500
      }
    );

    if (response.responseCode !== 200) {
      throw new Error(`HTTP ${response.responseCode}`);
    }

    return JSON.parse(response.result as string) as DeviceStatus;
  } finally {
    request.destroy();
  }
}

页面每两秒查询一次 /api/status。轮询失败时立即清除旧状态,不能继续显示上一次的“设备连接正常”:

private markDisconnected(
  statusText: string,
  errorText: string
): void {
  this.connected = false;
  this.alarmEnabled = false;
  this.manualOn = false;
  this.motionDetected = false;
  this.buzzerOn = false;
  this.statusText = statusText;
  this.errorText = errorText;
  this.lastUpdateTime = '--';
}

private async refreshStatus(): Promise<void> {
  if (this.requestBusy || this.commandWaiting) {
    return;
  }

  this.requestBusy = true;

  try {
    const status: DeviceStatus =
      await this.requestDevice('/api/status');

    this.applyDeviceStatus(status);
  } catch (_) {
    this.markDisconnected(
      '开发板连接失败',
      '请确认手机和开发板处于同一局域网'
    );
  } finally {
    this.requestBusy = false;
  }
}

状态轮询和控制命令还需要串行执行。开发板的 socket 资源有限,如果手机同时发出大量状态请求和控制请求,容易造成连接异常或资源耗尽。

实际遇到的几个坑

第一,HTTP 请求结束后只关闭 clientFd,不要正常处理一次请求就关闭 serverFd。否则服务端只能短暂访问,后面会出现端口占用或监听失败。

第二,不要同时混用 shutdown、close 和 lwip_close。当前方案统一使用 lwIP 接口,每个客户端连接只执行一次 lwip_close。

第三,这套组合中使用 select 曾经触发 LOS_EventRead failed,后面改成阻塞式 lwip_accept,服务才稳定下来。

第四,errno 98 通常表示端口仍被旧监听占用;errno 105 通常表示 lwIP socket 或 netconn 资源被耗尽。遇到这两个错误,首先检查是否反复创建 server socket,以及 clientFd 是否真的关闭。

第五,手机显示“指令执行成功”只能证明 HTTP 请求收到了成功响应,不能证明硬件一定执行成功。调试阶段一定要同时打印 PWM 返回值。

第六,手机换到另一个网络后仍显示在线,一般不是开发板还能跨网访问,而是手机界面保留了旧状态。在线状态必须由当前一次 /api/status 请求决定。

第七,Ninja 最后显示的 Code 4000 或 Code 4003 只是汇总信息,真正的错误要看 error.log 中第一条 error。快速检查命令如下:

grep -nE "FAILED:|error:|undefined reference|multiple definition|not used" \
out/bearpi_hm_nano_2/bearpi_hm_nano_2/error.log | head -50

第八,完整替换 C 文件时不要把新代码追加在旧代码后面。否则很容易出现 redefinition 和 defined but not used。

第九,修改代码后如果串口看不到新增日志,要检查新字符串是否进入最终固件。源码改对了但烧录旧包,也是嵌入式开发中很常见的问题。

编译、烧录与验证

修改完成后执行完整构建:

cd /home/bearpi/project/bearpi-hm_nano_2
rm -rf out/bearpi_hm_nano_2
hb build -f

完整烧录使用当前构建生成的 ws63-liteos-app_all.fwpkg。烧录结束后再按一次 Reset,让新固件运行。

电脑端可以先使用 curl 验证,确认开发板稳定后再调试手机:

curl --max-time 3 http://开发板IP:8888/api/status
curl --max-time 3 http://开发板IP:8888/api/manual/on
curl --max-time 3 http://开发板IP:8888/api/silence
curl --max-time 3 http://开发板IP:8888/api/manual/on
curl --max-time 3 http://开发板IP:8888/api/silence

验收时至少连续执行两轮鸣响和停止。如果第一次正常、第二次失败,要重点观察串口中是否出现 4294967295,以及第二次是否真正进入蜂鸣器硬件控制函数。

演示视频怎么处理

CSDN 的创作中心支持上传视频,也支持在文章编辑器中插入账号下已经上传并审核通过的视频。录屏本身不是在 CSDN 编辑器里完成,先使用手机或电脑自带的屏幕录制功能生成视频,再上传到 CSDN 即可。

微信普通小程序可以播放视频,但没有面向普通小程序开放“录制整个手机屏幕”的通用接口。小游戏有单独的 GameRecorderManager,不能直接当作普通小程序录屏方案。普通小程序演示建议使用手机系统录屏,再把视频上传到对象存储、云点播或其他 HTTPS 地址,通过 video 组件播放。

为了兼顾 CSDN、微信小程序、Android、iOS 和 HarmonyOS,建议统一导出为 MP4,视频编码使用 H.264,音频编码使用 AAC。分辨率使用 1080p 或 720p,帧率 30 fps 即可。不要直接使用 macOS 默认生成的 MOV 作为最终发布文件,先转成 MP4 会更稳妥。

演示视频控制在两到四分钟比较合适,重点展示实际效果和关键串口日志,不需要把全部编译过程都录进去。录制前注意隐藏 Wi-Fi 密码、个人 IP、公网服务器密码、设备序列号和其他敏感信息。

最后

这个小项目真正麻烦的地方并不在页面按钮,而是在嵌入式状态、PWM 生命周期、Socket 资源和手机在线状态之间保持一致。

局域网版本跑通后,下一步可以增加 DHCP 地址保留或 mDNS 自动发现,解决每次输入 IP 的问题。如果需要跨网络控制,再考虑 MQTT 或 HTTPS 服务,不建议直接把开发板的 8888 端口暴露到公网。

相关资料:

  • BearPi-HM Nano 2 官方仓库:https://gitee.com/bearpi/bearpi-hm_nano_2
  • E53_IS1 官方示例:https://gitee.com/bearpi/bearpi-hm_nano_2/tree/master/device/board/bearpi/bearpi_hm_nano_2/app/C5_e53_is1_infrared
  • HarmonyOS HTTP API:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-http
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐