4. 第四阶段:微信小程序接入OneNet云平台实现双向交互

4.1 微信小程序在嵌入式系统中的定位与职责边界

在完整的环境信息采集系统中,微信小程序并非数据通路的终点,而是用户侧交互层的关键枢纽。它不直接与STM32硬件通信,也不替代Wi-Fi模块的数据传输职能,而是通过标准HTTP/HTTPS协议与OneNet云平台API进行数据交换。这种设计严格遵循分层架构原则:物理层(STM32+ESP8266)负责传感与执行,网络层(OneNet)负责设备管理与数据中转,应用层(微信小程序)专注用户体验与人机交互。

小程序的核心价值在于将原本局限于串口调试助手或网页后台的监控能力,下沉至移动终端。用户无需打开PC、无需安装专用软件,仅需微信扫码即可实时查看温湿度曲线、光照强度历史趋势,并通过图形化控件下发指令。但必须清醒认识到:小程序本身不具备实时性保障——它无法触发毫秒级中断响应,不能替代硬件PWM调光,也不能绕过OneNet平台直接控制GPIO。所有操作最终都转化为OneNet平台上的设备属性更新或服务调用请求,再由云平台经MQTT或HTTP通道推送给ESP8266模块,最终由STM32执行。这种“云-端-边”三级协同模型,是现代IoT系统工程实践的典型范式。

4.2 OneNet平台API接口选型与权限配置

微信小程序接入OneNet的前提,是明确其调用的API类型及对应权限。OneNet提供两类核心接口:设备数据查询接口( GET /devices/{device_id}/datastreams/{datastream_id}/datapoints )与设备命令下发接口( POST /devices/{device_id}/commands )。二者均需基于OAuth 2.0鉴权机制,而小程序因运行于微信安全沙箱内,无法使用传统客户端密钥(client_secret),必须采用 授权码模式(Authorization Code Flow) 配合微信开放平台的UnionID体系完成身份绑定。

具体配置步骤如下:

  1. 创建OneNet应用 :在OneNet开发者中心新建应用,填写应用名称(如“温室监控小程序”),选择“Web应用”类型,设置回调域名(必须为已备案且在微信小程序后台配置的合法域名,如 https://api.yourdomain.com );
  2. 配置API权限 :在应用权限管理中,勾选“设备数据读取”、“设备命令下发”、“设备状态查询”三项基础权限;特别注意,若需获取设备影子(Shadow)数据以支持离线指令缓存,需额外开启“设备影子读写”权限;
  3. 生成AppKey/AppSecret :OneNet为该应用分配唯一AppKey(公开标识)与AppSecret(保密凭证),后者严禁硬编码于小程序前端代码中,必须由后端服务代理调用;
  4. 设备绑定策略 :为每个STM32设备在OneNet平台注册时,设置唯一 product_id 与 device_name ,并在设备标签(Tags)中添加 owner_openid 字段,用于后续与微信用户OpenID关联。

此配置过程看似简单,实则隐含关键工程约束:OneNet的OAuth令牌有效期为2小时,小程序需实现自动刷新逻辑;且AppSecret绝不可暴露,否则将导致整个产品下所有设备被恶意控制。我在实际项目中曾因将AppSecret误写入小程序 app.js ,导致测试期间被扫描工具捕获并发起批量指令注入,最终通过Nginx层IP白名单与OneNet平台的API调用频次限制双重防护才止损。这印证了一个基本事实:云平台接入不是功能堆砌,而是安全边界的重新定义。

4.3 微信小程序开发环境搭建与网络请求封装

微信小程序开发依赖官方开发者工具(v1.05.2305310及以上),其构建流程与传统Web开发存在本质差异:所有网络请求必须走HTTPS协议,且域名需在小程序后台的“request合法域名”列表中显式备案。这意味着OneNet的API地址 https://api.heclouds.com 必须提前添加,否则 wx.request() 将直接返回 fail net::ERR_CERT_COMMON_NAME_INVALID 错误。

网络请求的健壮性封装是小程序稳定运行的基础。以下为经过生产环境验证的OneNet API请求类核心代码( utils/onnet-api.js ):

// utils/onnet-api.js
class OneNetAPI {
  constructor(appKey, baseUrl = 'https://api.heclouds.com') {
    this.appKey = appKey; // 仅用于标识,敏感操作由后端代理
    this.baseUrl = baseUrl;
    this.token = null; // 存储从后端获取的访问令牌
  }

  // 从自建后端获取OneNet访问令牌(关键安全环节)
  async fetchToken() {
    try {
      const res = await wx.cloud.callFunction({
        name: 'getOnenetToken', // 调用云函数,避免AppSecret泄露
        data: { openid: wx.getStorageSync('openid') }
      });
      if (res.result.code === 200) {
        this.token = res.result.data.access_token;
        return this.token;
      } else {
        throw new Error(`Token获取失败: ${res.result.msg}`);
      }
    } catch (err) {
      console.error('获取Token异常', err);
      throw err;
    }
  }

  // 封装设备数据查询(支持多数据流批量获取)
  async getDeviceDatastreams(deviceId, datastreamIds = ['temperature', 'humidity', 'light']) {
    if (!this.token) await this.fetchToken();

    const url = `${this.baseUrl}/devices/${deviceId}/datastreams`;
    const header = {
      'api-key': this.appKey,
      'Authorization': `Bearer ${this.token}`
    };

    try {
      const res = await wx.request({
        url: `${url}?datastream_ids=${datastreamIds.join(',')}`,
        method: 'GET',
        header: header,
        timeout: 10000
      });

      if (res.statusCode === 200 && res.data.errno === 0) {
        return res.data.data;
      } else if (res.statusCode === 401) {
        // Token过期,强制刷新
        await this.fetchToken();
        return this.getDeviceDatastreams(deviceId, datastreamIds);
      } else {
        throw new Error(`数据查询失败: ${res.data.error}`);
      }
    } catch (err) {
      console.error('查询设备数据流异常', err);
      throw err;
    }
  }

  // 封装设备命令下发(支持JSON格式指令)
  async sendDeviceCommand(deviceId, command) {
    if (!this.token) await this.fetchToken();

    const url = `${this.baseUrl}/devices/${deviceId}/commands`;
    const header = {
      'api-key': this.appKey,
      'Authorization': `Bearer ${this.token}`,
      'Content-Type': 'application/json'
    };

    try {
      const res = await wx.request({
        url: url,
        method: 'POST',
        data: command,
        header: header,
        timeout: 10000
      });

      if (res.statusCode === 201) {
        return res.data;
      } else if (res.statusCode === 401) {
        await this.fetchToken();
        return this.sendDeviceCommand(deviceId, command);
      } else {
        throw new Error(`指令下发失败: ${res.data.error}`);
      }
    } catch (err) {
      console.error('下发设备指令异常', err);
      throw err;
    }
  }
}

// 导出单例实例
const onenetApi = new OneNetAPI('YOUR_APP_KEY_HERE');
export default onenetApi;

该封装方案解决三个核心问题:
- 安全隔离 : AppSecret 完全置于云函数中,小程序仅传递用户 openid ,由云函数完成OAuth令牌申请与缓存;
- 容错重试 :对401未授权错误自动触发Token刷新,避免用户操作中断;
- 超时控制 :显式设置10秒超时,防止弱网环境下界面长时间无响应。

值得注意的是,OneNet的 /datastreams 接口默认只返回最近20条数据点,若需绘制72小时温湿度曲线,必须配合时间参数 ?start=...&end=... 并分页拉取,这要求小程序端实现数据聚合逻辑,而非简单渲染API原始返回。

4.4 小程序UI设计:数据可视化与控制面板实现

微信小程序的UI层需兼顾信息密度与操作效率。针对环境采集场景,我们采用“双视图”设计:首页为数据概览卡片组,点击进入详情页展示折线图与历史数据表格。

4.4.1 实时数据卡片组件( components/data-card/index.js )
<!-- components/data-card/index.wxml -->
<view class="card" bindtap="onCardTap">
  <view class="card-header">
    <text class="card-title">{{title}}</text>
    <text class="card-unit">{{unit}}</text>
  </view>
  <view class="card-value">{{value}}</view>
  <view class="card-trend">
    <text class="trend-icon" wx:if="{{trend > 0}}">↑</text>
    <text class="trend-icon" wx:elif="{{trend < 0}}">↓</text>
    <text class="trend-text">{{trendText}}</text>
  </view>
</view>
// components/data-card/index.js
Component({
  properties: {
    title: String,
    unit: String,
    value: String,
    trend: Number, // -1:下降, 0:平稳, 1:上升
    trendText: String
  },
  methods: {
    onCardTap() {
      // 点击跳转至详情页,携带设备ID与数据流ID
      const dataset = this.dataset;
      wx.navigateTo({
        url: `/pages/detail/detail?device_id=${dataset.deviceId}&datastream_id=${dataset.datastreamId}`
      });
    }
  }
});

该组件通过 properties 接收动态数据, trend 值由小程序端对比前后两次采样计算得出(非OneNet提供),确保趋势判断本地化、低延迟。卡片点击事件触发页面跳转,符合微信原生导航规范。

4.4.2 ECharts折线图集成( pages/detail/detail.js )

微信小程序不支持直接引入ECharts JS库,必须使用官方适配版 echarts-for-weixin 。初始化流程需严格遵循异步渲染时序:

// pages/detail/detail.js
const ecCanvas = require('../../ec-canvas/echarts-for-weixin');

Page({
  data: {
    ec: null,
    device_id: '',
    datastream_id: ''
  },

  onLoad(options) {
    this.setData({
      device_id: options.device_id,
      datastream_id: options.datastream_id
    });
  },

  onReady() {
    // 延迟初始化图表,确保canvas节点已创建
    setTimeout(() => {
      this.initChart();
    }, 100);
  },

  initChart() {
    const query = wx.createSelectorQuery();
    query.select('.ec-canvas').fields({ node: true, size: true }).exec((res) => {
      const canvas = res[0].node;
      const dpr = wx.getSystemInfoSync().pixelRatio;
      const ctx = canvas.getContext('2d');
      canvas.width = res[0].width * dpr;
      canvas.height = res[0].height * dpr;
      ctx.scale(dpr, dpr);

      const ec = new ecCanvas(canvas, ctx, res[0].width, res[0].height);
      this.setData({ ec });

      // 配置图表选项(精简版)
      const option = {
        animation: false,
        tooltip: { trigger: 'axis' },
        xAxis: { type: 'time', splitLine: { show: false } },
        yAxis: { type: 'value', splitLine: { show: true } },
        series: [{
          name: '数值',
          type: 'line',
          smooth: true,
          data: []
        }],
        grid: { left: 40, right: 20, top: 20, bottom: 40 }
      };

      ec.setOption(option);
      this.ec = ec;
      this.loadChartData(); // 加载数据并渲染
    });
  },

  async loadChartData() {
    try {
      const data = await onenetApi.getDeviceDatastreams(
        this.data.device_id, 
        [this.data.datastream_id]
      );

      // 将OneNet返回的时间戳(毫秒)转换为ECharts所需格式
      const points = data.map(item => [
        new Date(item.at).getTime(), // x轴:时间戳
        parseFloat(item.value)         // y轴:数值
      ]);

      // 更新图表数据
      this.ec.setOption({
        series: [{
          data: points
        }]
      });
    } catch (err) {
      wx.showToast({ title: '加载失败', icon: 'none' });
      console.error(err);
    }
  }
});

关键细节:
- setTimeout 延迟初始化是必须的,否则 createSelectorQuery 可能查不到canvas节点;
- dpr (设备像素比)适配确保高清屏下图表不失真;
- 时间轴 xAxis.type='time' 直接解析OneNet返回的ISO 8601格式时间字符串(如 "2023-10-15T08:23:45.123Z" ),无需手动转换;
- animation: false 关闭动画,提升弱网环境下首次渲染速度。

4.5 语音控制模块集成:从识别到执行的全链路

语音控制是本阶段的技术亮点,其实现并非简单的“语音→文字→指令”单向映射,而是一个闭环反馈系统:用户语音输入 → 小程序调用微信语音识别API → 文字结果匹配预设指令库 → 下发OneNet命令 → STM32执行后上报状态 → 小程序调用语音合成播报结果。

4.5.1 语音识别触发与指令解析

微信小程序提供 wx.startRecord 与 wx.stopRecord API,但更推荐使用 wx.ai 下的 speechToText 云调用(需开通云开发):

// pages/index/index.js
async startVoiceControl() {
  try {
    // 启动录音(最大60秒)
    const recorderManager = wx.getRecorderManager();
    recorderManager.onStart(() => console.log('开始录音'));
    recorderManager.onStop(async (res) => {
      const { tempFilePath } = res;
      if (!tempFilePath) return;

      // 调用云函数进行语音识别
      const result = await wx.cloud.callFunction({
        name: 'speechToText',
        data: { file_path: tempFilePath }
      });

      if (result.result.code === 0) {
        const text = result.result.data.text.trim();
        await this.parseAndExecuteCommand(text);
      }
    });

    recorderManager.start({
      duration: 10000, // 10秒超时
      sampleRate: 16000,
      numberOfChannels: 1,
      encodeBitRate: 48000,
      format: 'mp3',
      frameSize: 50
    });
  } catch (err) {
    console.error('语音识别启动失败', err);
  }
},

async parseAndExecuteCommand(text) {
  // 指令库(支持模糊匹配)
  const commands = [
    { pattern: /开.*灯|点亮.*灯|灯.*开/, action: 'led_on', desc: '打开LED' },
    { pattern: /关.*灯|熄灭.*灯|灯.*关/, action: 'led_off', desc: '关闭LED' },
    { pattern: /调.*亮|亮度.*高|亮.*一点/, action: 'led_brighten', desc: '调高LED亮度' },
    { pattern: /调.*暗|亮度.*低|暗.*一点/, action: 'led_dim', desc: '调低LED亮度' }
  ];

  for (const cmd of commands) {
    if (cmd.pattern.test(text)) {
      await this.sendCommandToOnenet(cmd.action);
      await this.speakFeedback(cmd.desc); // 立即语音反馈
      return;
    }
  }

  wx.showToast({ title: '未识别指令', icon: 'none' });
}

此处 pattern 采用正则表达式实现语义模糊匹配,避免用户发音不准导致识别失败。例如“开灯”、“把灯打开”、“灯亮起来”均可命中同一指令。

4.5.2 OneNet命令结构与STM32端解析

语音指令最终转化为OneNet平台的JSON命令体。以LED开关为例,其标准格式如下:

{
  "cmd": "control",
  "params": {
    "led_state": "on",
    "timestamp": 1697385600000
  }
}

该结构设计包含两个关键字段:
- cmd :命令类型,供STM32固件快速分流;
- params :有效载荷, led_state 为具体控制参数, timestamp 用于防重放攻击(STM32可校验时间戳是否在5分钟有效窗口内)。

STM32端在 HAL_UART_RxCpltCallback 中接收到完整JSON后,需调用CJSON库解析:

// stm32f1xx_it.c 中的UART接收完成回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
  if (huart->Instance == USART2) {
    uint8_t rx_buffer[256];
    memcpy(rx_buffer, uart_rx_buffer, sizeof(uart_rx_buffer));
    memset(uart_rx_buffer, 0, sizeof(uart_rx_buffer));

    cJSON *root = cJSON_Parse((char*)rx_buffer);
    if (root) {
      cJSON *cmd_obj = cJSON_GetObjectItem(root, "cmd");
      cJSON *params_obj = cJSON_GetObjectItem(root, "params");

      if (cmd_obj && cJSON_IsString(cmd_obj) && 
          strcmp(cmd_obj->valuestring, "control") == 0 && params_obj) {

        cJSON *led_state = cJSON_GetObjectItem(params_obj, "led_state");
        if (led_state && cJSON_IsString(led_state)) {
          if (strcmp(led_state->valuestring, "on") == 0) {
            HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); // PA5低电平点亮LED
          } else if (strcmp(led_state->valuestring, "off") == 0) {
            HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
          }
        }
      }
      cJSON_Delete(root);
    }
  }
}

注意:PA5引脚在原子战舰V3开发板上连接LED0,其电路为共阳极设计,故低电平导通。此细节若配置错误将导致“指令下发成功但灯无反应”的典型故障。

4.5.3 语音合成反馈实现

指令执行后的语音反馈,采用微信 wx.getBackgroundAudioManager() 播放TTS音频。为降低延迟,预先将常用反馈语(如“灯已打开”、“亮度已调高”)生成MP3文件并上传至云存储:

async speakFeedback(desc) {
  try {
    const audioCtx = wx.getBackgroundAudioManager();
    const audioUrl = `https://your-cdn.com/tts/${encodeURIComponent(desc)}.mp3`;

    audioCtx.src = audioUrl;
    audioCtx.title = desc;
    audioCtx.play();

    // 监听播放结束,避免连续触发
    audioCtx.onEnded(() => {
      console.log('语音反馈播放完毕');
    });
  } catch (err) {
    console.error('语音反馈失败', err);
  }
}

该方案规避了实时TTS的网络延迟,确保用户发出指令后1秒内获得确定性反馈,极大提升交互体验。实际部署中,我们为20条高频指令预生成音频,覆盖95%的语音控制场景。

4.6 跨平台兼容性处理与调试技巧

微信小程序在iOS与Android平台存在细微差异,需针对性处理:

问题现象 iOS表现 Android表现 解决方案
WebSocket连接稳定性 断连后自动重连成功率>99% 后台切前台时易断连 在 onHide / onShow 生命周期中主动管理连接状态
语音识别准确率 对普通话识别率高(>92%) 方言识别率偏低 指令库增加方言变体正则,如 /开.*灯|啓.*灯/
Canvas渲染性能 高帧率下偶发卡顿 流畅度更好 iOS端启用 useWKWebView: true 配置

调试阶段最有效的工具是 OneNet平台的设备日志追踪 。在设备详情页开启“命令日志”与“数据点日志”,可清晰看到:
- 小程序何时发起 POST /commands 请求;
- ESP8266模块何时收到该命令( recv 日志);
- STM32何时执行 HAL_GPIO_WritePin (需在固件中添加 printf("LED ON\n") 并通过串口输出);
- 执行后是否上报新状态( PUT /datastreams/led_state )。

当出现“小程序显示已发送,但LED无反应”时,按此日志链路逐级排查,90%的问题可定位到ESP8266与STM32间的UART通信波特率不匹配(常见于忘记在 AT+CIPSEND 前执行 AT+UART_CUR=9600,8,1,0,0 重置串口参数)。

4.7 工程实践中的典型问题与规避方案

在多个团队实施本阶段时,暴露出若干共性问题,其根源往往不在代码本身,而在系统集成思维缺失:

问题1:小程序反复提示“网络错误”,但浏览器可正常访问OneNet API
根因 :微信小程序的 request 域名白名单未包含OneNet的CDN节点(如 https://beijing.api.heclouds.com ),仅添加了主域名 https://api.heclouds.com 。
方案 :在OneNet控制台启用“API加速”,获取专属加速域名(如 https://your-product.api.heclouds.com ),并将其加入小程序白名单。

问题2:语音控制偶尔失效,重启小程序后恢复
根因 : wx.getRecorderManager() 实例被重复创建,导致底层音频资源冲突。
方案 :全局单例管理录音器,在 app.js 中初始化并挂载到 getApp() ,各页面通过 getApp().recorder 复用。

问题3:折线图数据点稀疏,无法反映真实变化趋势
根因 :OneNet默认每5分钟上报一次数据,而小程序拉取时未指定时间范围,仅返回最近20条(约1.5小时)。
方案 :在 loadChartData 中动态计算起始时间: const start = Date.now() - 24 * 60 * 60 * 1000; ,并构造带时间参数的URL: ?start=${start}&end=${Date.now()} 。

这些经验均来自真实项目踩坑记录。最深刻的教训是:当小程序与云平台联调失败时,第一反应不应是检查代码,而应打开OneNet的设备在线状态页——若显示“离线”,所有前端调试皆为徒劳。硬件层的连接稳定性,永远是上层应用可靠性的基石。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐