12728 字
64 分钟
MAYA1.0:物联网管家桌面机器人

MAYA1.0 是一台摆在小桌面上的物联网管家机器人。

它想解决的问题很朴素:家里那些琐碎的事,开灯、关灯、看看屋里闷不闷、有没有人——能不能交给一个摆在桌面上的小家伙,让它用说话的方式跟你商量着办完。

具体来说,它围绕「感知环境 → 给出建议 → 动手执行 → 回显结果」的闭环,把五件事串了起来:

  • 语音唤醒——用特定的唤醒词叫醒它(ASR-PRO 离线语音模块)
  • 播报环境特征——采集温湿度、光照和自身姿态,念给你听(传感器系统 + 喇叭)
  • 确认管理方案——基于环境数据推荐一套家电管理方案,你可以同意,也可以拒绝后自己用语音改(语音模块)
  • 执行用户命令——真正去开关家电(ESP8266 Wi-Fi 模块)
  • 显示家居状态——LCD 上逐个回显各电器的开关状态(LCD 显示屏)

除了这条”叫它才走”的闭环,它还有几件后台常驻的活儿——持续环境检测、表情与肢体互动、以及基于视觉的自动照明,后面会细说。

先看它跑起来的样子:

关于这段视频

俯拍视角一镜到底,记录 MAYA1.0 实机跑动,约 37 秒,无声。

它是什么#

它的核心能力可以归成三类:

  • 语音聊天——用自然语言对话的方式接收命令、反馈状态
  • 环境监测——持续采集周围环境特征,判断”屋里现在是什么状况”
    • 温度、湿度(DHT11)
    • 光照强度(光敏电阻 + ADC)
    • 自身姿态(MPU6050 六轴)
  • 智能家居自动管理——根据环境数据推荐家电管理方案,并真正去控制家电
MAYA1.0物联网管家机器人定义基于 STM32 的智能助手桌面机器人执行基于物联网的自动化任务核心能力语音聊天——用自然语言对话环境监测——温湿度 / 光照 / 姿态智能家居自动管理——推荐并控制家电任务占比语音交互环境探测物联网管理娱乐

和市面上那些”必须联网、必须连手机 App、开口先转三秒圈”的语音助手不同,MAYA1.0 的定位是离线的、即时的、看得见摸得着的桌面伙伴。它不依赖云端大模型,唤醒词一说就醒,命令一下就走。

一条完整的交互闭环#

MAYA1.0 的工作流程不是简单的”听到命令 → 执行”,而是围绕环境感知设计了一个五步闭环:

工作流程五步闭环① 语音唤醒ASR-PRO 离线语音模块用特定唤醒词唤醒,不必一直“偷听”② 播报环境特征传感器系统 + 喇叭接收环境数据 → 处理 → 播报当前环境③ 确认管理方案基于环境特征主动推荐家电方案用户可同意,也可用语音自定义开关④ 执行用户指令ESP8266 Wi-Fi 模块通过通讯模块远程控制家用电器⑤ 显示家居状态LCD 显示屏执行成功后显示各电器开关状态持续环境探测(后台常驻)传感器系统 + 机器视觉模块人脸数据 → 特定时间自动调整照明

1. 语音唤醒

通过 ASR-PRO 离线语音模块,用特定的唤醒词进行语音唤醒。唤醒是整条链路的第一道闸门——只有被叫到名字,机器人才开始工作,避免它无时无刻都在”偷听”。

2. 播报环境特征

被唤醒后,主控芯片通过传感器系统接收环境特征数据进行处理,并用喇叭播报当前的环境特征。比如”现在屋内温度 26 度,光照偏暗”——先把它看到的告诉你。

3. 确认管理方案

基于环境特征数据,机器人会主动推荐相应的家用电器管理方案。这里的关键是”商量”而不是”独断”:

  • 用户可以拒绝方案,并用语音命令自定义电器的开关状态
  • 也可以同意原方案直接执行

4. 执行用户命令

通过 ESP8266 WiFi 模块,实现对家用电器的远程控制。这是”物联网”三个字落地的地方——机器人本体只是个大脑,真正的执行器在墙上插座里。

5. 显示家居状态

用户命令执行成功后,树莓派驱动的 LCD 显示屏上显示各个家用电器的开关状态。让每一次操作都有明确回执,用户不用猜”到底成功没有”。

闭环之外,还有三个让我比较得意的设计:

  • 持续环境检测——环境数据不是问一次采一次,而是持续滚动更新,机器人始终知道屋里现在什么样
  • 表情与肢体互动——LCD 屏上展示各种有趣的表情,并相应改变机械结构,做出生动的人机互动
  • 基于视觉的自动照明——通过机器视觉系统识别的人体数据,在特定时间调整灯光的自动照明方案(比如识别到有人进房间才亮灯,而不是靠定时器瞎猜)

它内部其实是一台状态机#

上面这五步听起来像一条直线,但真跑起来并不是”一步一步往下走”——用户随时可能插话、传感器随时可能报警、网络随时可能掉线。所以主控里跑的其实是一个四态状态机,配合一个 50 ms 的心跳节拍。

主程序流程图

状态触发条件行为
ST_IDLE上电 / 40 s 无操作只等唤醒词,LCD 播待机呼吸动画
ST_ACTIVE唤醒词命中采集环境、分发命令、处理视觉与网络事件
ST_ALARMMPU6050 检测到倾倒播报警动画,暂停执行动作
ST_FAULT看门狗复位 / 自检失败显示错误动画,等待人工处理
为什么非要状态机

如果没有状态机,“语音命令”和”定时采集”这两件事会在同一个 while(1) 里互相打断:采集到一半来了语音,喇叭播报和数据处理就撞车。状态机把谁能在这个状态下做动作写死,冲突就从”偶发 bug”变成了”设计约束”。

系统架构:一颗 STM32,加一块树莓派#

看第一眼会觉得奇怪:为什么不用一块树莓派全干了?树莓派性能强得多,能跑 Python、能驱动屏幕、能连 Wi-Fi。

答案是实时性和动画流畅度要的其实是两种东西

STM32 负责的是”永远按时回话”——中断响应微秒级、没有操作系统调度抖动,语音模块的串口数据、传感器的定时采样、舵机的 PWM 时序,这些都不能等。而树莓派负责的是”画面好看”——它跑 Linux、有 GPU、能流畅放 24 fps 的表情动画,但它的 GPIO 时序受调度影响,拿它做实时控制是灾难。

所以 MAYA1.0 用的是双核分工:STM32 做决策,树莓派做脸。

系统整体设计框图

硬件职责为什么是它
主控层STM32F103C8T6传感器采集、指令调度、实时控制Cortex-M3 @72 MHz,中断确定,无 OS 抖动
交互层树莓派 2B表情动画、状态可视化能跑 Linux + Python,图形性能足够
感知层ASR-PRO / OpenMV / DHT11 / LDR / MPU6050听、看、测环境各自封好接口,主控只读结果
网络层ESP8266-01S × 4家电远程控制内置完整 TCP/IP 协议栈
执行层SG90 舵机 / L298N + 直流电机结构动作与行走扭矩够、驱动简单
分工的边界怎么划

判断标准只有一条:这件事能不能容忍几十毫秒的抖动? 不能容忍的(语音收包、传感器采样、PWM 时序)放 STM32;能容忍的(动画、界面、日志)放树莓派。两边只用一条 115200 的串口连着,协议简单到就是几行文本,反而最不容易出问题。

硬件选型#

整台机器人由四个核心模块 + 一块显示屏构成:

MAYA1.0 实物:履带底盘、LCD 显示屏、摄像头与主控板

主要模块介绍STM32F103C8T6最小系统板主控:整体的决策与控制接收传感器数据 → 处理 → 决策运动 / 显示 / 推荐ASR-PRO 离线语音模块天问 block 出品,支持图形化编程识别唤醒命令与声音命令,保证有效互动OpenMV 机器视觉模块基于 STM32 的开源视觉识别模块电脑端 IDE · MicroPython 编程ESP8266 Wi-Fi 通讯模块低成本 Wi-Fi 微控制器完整 TCP/IP 协议栈,物联网设备常用

(1)STM32F103C8T6 最小系统板——主控#

用 STM32 单片机作为主控芯片,负责整体的决策和控制。通过接收传感器数据、执行数据处理和逻辑,决策机器人的运动、显示、推荐方案等操作。

选它的理由是实时性和确定性:这颗 Cortex-M3 内核的芯片主频 72MHz、64KB Flash、20KB SRAM,虽然远不如树莓派”能跑大程序”,但它没有操作系统调度的不确定性,中断响应是微秒级的。对于一个要实时响应语音唤醒、实时驱动舵机做表情的桌面机器人来说,永远按时回话比偶尔能干大事更重要

所谓”最小系统板”,就是只保留芯片运行所必需的最小外围电路(8 MHz 晶振、复位、电源、下载口),便宜、干净、把接口全部留给我们自己规划。时钟一路经过内部 PLL 倍频到 72 MHz,同时开了独立看门狗(IWDG)和窗口看门狗(WWDG)——只要程序跑飞或者卡死循环,硬件自己把机器复位,不需要人爬上桌子拔电。

电源这边是 LM2596 先把电池降到 5 V,再经 LD1117 稳压出 3.3 V;另外引了一路分压到 ADC,用来实时监测电量、做欠压保护。

STM32 最小系统板与两路环境传感器的接线:左侧光敏电阻模块、右侧 DHT11,红黑为电源与地、黄绿为信号线

(2)ASR-PRO 离线语音模块——耳朵和嘴#

ASR-PRO 是天问 block 公司发布的离线语音模块,可以用 C++ 和电脑端 IDE 的图形化编程方式来开发。用于识别人的唤醒命令和其他声音命令,保证机器人和用户的有效互动。

“离线”这两个字是重点:

离线语音(MAYA1.0 采用)在线语音
延迟毫秒级通常 1 秒以上
网络不需要断网即瘫
隐私音频不出设备上传云端
成本一颗模块持续调用费用

代价是它只认识你预先定义好的一批命令词。但对管家机器人这个场景来说,开灯 / 关灯 / 现在几度这类封闭指令集恰恰是最需要的部分。

硬件上它通过 3.3 V TTL 电平的 UART 跟 STM32 通信,波特率 115200;前端是一颗带降噪的 MEMS 数字麦克风,电源上加了一级 LC 滤波专门滤掉 50 Hz 的电网干扰。

ASR-PRO 离线语音模块

(3)OpenMV 机器视觉模块——眼睛#

基于 STM32 微处理器的开源视觉识别模块,可用电脑端 IDE 上的 MicroPython 编程。MAYA1.0 用的是 OpenMV Cam M7:1/4″ CMOS 摄像头加一颗 STM32F7 内核,能在 640×480 下跑到 30 fps,板上就把基础图像算法算完,只把结果吐出来。

它的价值在于把”图像采集 + 算法处理 + 结果输出”封装成一块可以直接读结果的模块:你不用去啃摄像头驱动的时序,直接 find_blobs()find_features() 就能拿到目标坐标。MAYA1.0 主要用它做两件事:

  1. 人脸检测——识别到主人来了就触发个性化问候
  2. 环境亮度分析——跟光敏电阻互为补充,极端光照下也能判断环境

结果通过 UART 以 JSON 形式上报,简单到就是 {"face":true}{"brightness":75} 这种程度。

OpenMV Cam 视觉模块

(4)ESP8266 WiFi 通讯模块——连上世界#

市面上常用的一种低成本 Wi-Fi 微控制器,内置一颗 32 位 Tensilica L106,主频 80 MHz,带完整的 TCP/IP 协议栈。板载天线加低功耗睡眠,非常适合嵌入式物联网场景。

严格说 ESP8266 自己就是一颗能独立跑程序的主控,MAYA1.0 这里把它当通信协处理器用——STM32 负责决策,ESP8266 负责把决策变成网络请求发出去控制家电。这种分工让主控不必负担 TCP/IP 协议栈的开销。

ESP8266-01S Wi-Fi 模块

(5)树莓派 2B + 3.5 寸 LCD——脸#

显示模块采用 3.5 寸 LCD 显示屏,配合树莓派 2B 驱动,可显示文字、图形、动画和机器人互动表情,清晰直观地呈现环境监测数据、家电控制状态及人机交互界面。

树莓派这一侧实际上只有一个开机自启的 Python 程序:监听串口、解析命令、用 pygame 播放动画。命令帧简单到就是把名字念一遍——ANIM:SMILEANIM:SADANIM:IDLE。这种”简陋”的协议是故意的:两边各自独立开发,只要串口对上就能联调。

树莓派 2B 与 3.5 寸 LCD

(6)SG90 舵机与直流电机——手和脚#

机器人结构动作和转向控制选用 SG90 舵机,STM32 输出 50 Hz 的 PWM 信号控制 0°–180° 角度,用来做头部细微动作。舵机电源线上加了一级 RC 抑制电路,降低开关噪声对主控的干扰。

α=τ0.5ms2.0ms×180,τ=CCRARR+120ms\alpha = \frac{\tau - 0.5\,\text{ms}}{2.0\,\text{ms}} \times 180^\circ, \qquad \tau = \frac{\text{CCR}}{\text{ARR}+1} \cdot 20\,\text{ms}

底盘则是两台 9 V 直流电机驱动履带,通过 L298N 双 H 桥实现双向调速;STM32 用 PWM 占空比控制速度,电机轴上的光电编码器把实际转速反馈回来,为后续的精确定位和路径规划留好接口。

SG90 舵机

(7)机械结构与外观——3D 打印外壳 + 积木骨架#

整机外观走的是机甲风格,外壳用 PLA 材料 3D 打印——既能支持复杂曲面和棱角,强度也够。建模在 SolidWorks 里完成:先画各外壳零件的草图,用拉伸、扫掠、倒角做出主体形状,再导出 STL 交给打印机。

骨架部分则是三百片模块化积木拼出来的。每个模块按标准化接口设计,既能快速装配,又能灵活调整布局去适配不同传感器、舵机和电机的位置。这个设计本质上是为迭代服务的——第一版装配完发现舵机线会干涉,改几个卡扣形状就能重来,不用重新打印整块外壳。经过多次迭代,我优化了模块连接的卡扣形状和定位销孔,保证结构在承载动力输出、反复运动之后不松动、不位移。

3D 打印并拼装的实物外观

一张自己组的局域网:四颗 ESP8266#

家用 Wi-Fi 往往是整个系统里最不可控的一环——路由器品牌各异、信号覆盖不均、还可能根本没有网。所以在网络设计上 MAYA1.0 干脆不依赖家里的路由器,而是自组了一个小型局域网。

STM32 主控ASR-PRO 离线语音ESP8266-A(AP 模式)192.168.4.1ESP8266-B(STA 模式)192.168.4.2ESP8266-C(STA 模式)192.168.4.3ESP8266-D(STA 模式)192.168.4.4UARTWi-Fi / UDP

方案是一主三从:

  • 主控端 A 配置为 AP 模式,自己开热点,供从控端接入
  • 从控端 B / C / D 连上 A,各自监听同一个 UDP 端口
  • STM32 通过 UART 把控制帧交给 A,A 再广播出去

指令格式简单到可以直接在纸上推演:

指令方向作用
LIGHT_ONA → B/C/D对应从控 GPIO0 拉高,继电器吸合
LIGHT_OFFA → B/C/D从控 GPIO0 拉低,继电器断开
OK:LIGHT_ON从控 → A执行回执,主控据此更新 LCD
ERR:UNKNOWN从控 → A未知指令,拒绝执行
为什么不走 TCP

我的第一版设计里写的是”TCP/UDP 混合”,但实际调试时我把控制帧全改成了 UDP。原因很直接:这种指令是幂等的且过期就该丢LIGHT_ON 晚到 3 秒送达,效果和立刻送到完全一样;但 TCP 的自动重传会让一条已经过期的命令在后面某个时刻突然生效——用户早就走开了,灯突然亮起来,比丢包更糟。UDP 的”不可靠”在这个场景里反而是一种保护。

代价是需要自己做应用层确认,也就是表里那两条回执帧。

代码分析#

下面是我在调试过程中真正跑通的几段关键实现。整个项目按模块拆成了 6 个部分:主循环调度、环境采集、语音解析、视觉检测、网络通信、显示驱动。

TIP

如果你想复现一套类似的系统,先把”STM32 发一行字符串 → 树莓派原样打印出来”这条最小链路跑通,再去接传感器和语音。我一开始跳过了这步直接上全套,第一次联调时十几个模块同时报错,根本不知道从哪一条查起。

主循环:心跳 + 事件队列 + 状态机#

主控的核心是不阻塞。任何一件事都不允许在主循环里等待——否则语音命令会被传感器采样卡住,或者反过来。所以所有子程序都是”有结果就置一个事件位”,主循环只负责按优先级消费。

Core/Src/main.c
/* MAYA1.0 主控:50 ms 心跳调度 + 事件队列 + 四态状态机 */
#include "app.h"
typedef enum { ST_IDLE, ST_ACTIVE, ST_ALARM, ST_FAULT } sys_state_t;
#define EV_ENV (1UL << 0) /* 环境数据就绪 */
#define EV_VOICE (1UL << 1) /* 收到语音命令 */
#define EV_VISION (1UL << 2) /* 视觉结果到达 */
#define EV_NET (1UL << 3) /* 家电回执到达 */
#define EV_ALARM (1UL << 4) /* 姿态异常 */
#define EV_TIMEOUT (1UL << 5) /* 40 s 无人操作 */
static volatile uint32_t g_events;
static sys_state_t g_state = ST_IDLE;
static uint16_t g_idle_ticks;
void app_post_event(uint32_t ev) { g_events |= ev; }
int main(void)
{
HAL_Init();
SystemClock_Config(); /* 8 MHz 晶振经 PLL 拉到 72 MHz */
MX_GPIO_Init();
MX_USART1_UART_Init(); /* ASR-PRO 115200 8N1 */
MX_USART2_UART_Init(); /* OpenMV 115200 8N1 */
MX_USART3_UART_Init(); /* ESP8266 115200 8N1 */
MX_ADC1_Init(); /* LDR 光敏电阻 */
MX_I2C1_Init(); /* MPU6050 */
MX_TIM1_Init(); /* SG90 50 Hz PWM */
env_init(); voice_init(); vision_init(); net_init();
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);
display_anim("ANIM:IDLE");
IWDG_ReloadCounter();
uint32_t beat = HAL_GetTick();
while (1) {
IWDG_ReloadCounter(); /* 喂狗:跑飞了硬件自动复位 */
if (HAL_GetTick() - beat >= 50) { /* 50 ms 心跳,不用 delay */
beat = HAL_GetTick();
app_post_event(EV_ENV);
if (mpu6050_tilted()) app_post_event(EV_ALARM);
if (++g_idle_ticks > 800) app_post_event(EV_TIMEOUT);
}
uint32_t ev = g_events;
if (!ev) continue; /* 没事件就放行,让中断去跑 */
g_events &= ~ev; /* 边沿消费,不重复触发 */
if (!(ev & EV_TIMEOUT)) g_idle_ticks = 0;
if (ev & EV_ALARM) g_state = ST_ALARM;
if (ev & EV_VOICE) g_state = ST_ACTIVE;
switch (g_state) {
case ST_IDLE:
break; /* 低位待机,只等唤醒词 */
case ST_ACTIVE:
if (ev & EV_ENV) env_report(); /* 采集 → 播报 */
if (ev & EV_VOICE) voice_dispatch(); /* 分发命令 */
if (ev & EV_VISION) vision_handle(); /* 有人/偏暗 */
if (ev & EV_NET) net_handle(); /* 回执 → 刷 LCD */
if (ev & EV_TIMEOUT) {
g_state = ST_IDLE;
display_anim("ANIM:IDLE");
}
break;
case ST_ALARM: display_anim("ANIM:SAD"); break;
case ST_FAULT: display_anim("ANIM:ERROR"); break;
}
}
}

注意这里有个反直觉的写法:心跳用的是 if (HAL_GetTick() - beat >= 50) 而不是 HAL_Delay(50)。区别在于 HAL_Delay 会让 CPU 空转 50 ms,这期间来的串口数据只能靠中断往缓冲区里塞,主循环完全僵住。用时间戳比较则让主循环跑满速,一旦有事件立刻处理,代价只是 CPU 占用高一点——对一块插着电的桌面机器人来说,这个交换很划算。

别在中断里做解析

app_post_event() 只做了一件 g_events |= ev,这是刻意的。我一开始图省事,在 UART 中断回调里直接解析语音命令并调用处理函数,结果串口一帧还没接收完,下一帧的字节就把缓冲区覆盖了——表现为”偶尔识别错命令”,而且几乎不可复现。中断里只搬字节,解析全部留给主循环,这个规矩定死之后这类 bug 就绝迹了。

环境采集:DHT11 时序 + LDR 双重滤波#

环境数据是后面一切推荐逻辑的依据,所以这一段宁可慢一点也不能脏。不过 DHT11 自己也有采样间隔的硬要求——主循环那 50 ms 的心跳只是”去问一声”,真正拿到新数据其实是一秒一次。

Core/Src/env.c
/* DHT11 走单总线,LDR 走 12 位 ADC —— 两个都得滤干净才敢上报 */
#include "env.h"
#include <string.h>
#define LDR_WIN 8
static uint8_t ldr_idx;
static uint16_t ldr_hist[LDR_WIN];
32 collapsed lines
/* —— DHT11:主机拉低 20 ms 起始,然后逐位读 40 bit —— */
static uint8_t dht11_read(uint8_t *temp, uint8_t *humi)
{
uint8_t data[5] = {0};
uint32_t t;
HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET);
HAL_Delay(20);
HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET);
t = HAL_GetTick(); /* 等 80 us 低 + 80 us 高应答 */
while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET)
if (HAL_GetTick() - t > 5) return 0;
t = HAL_GetTick();
while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET)
if (HAL_GetTick() - t > 5) return 0;
for (uint8_t i = 0; i < 40; i++) { /* 每位以 50 us 低电平开头 */
while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) {}
uint32_t high = 0;
while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) high++;
data[i / 8] <<= 1;
if (high > 20) data[i / 8] |= 1; /* 高电平长 = 1,短 = 0 */
}
if ((uint8_t)(data[0] + data[1] + data[2] + data[3]) != data[4])
return 0; /* 校验和不对,整帧丢掉 */
*humi = data[0];
*temp = data[2];
return 1;
}
/* —— LDR:8 次滑动窗口排序后掐头去尾,压掉日光灯 100 Hz 闪变 —— */
static uint16_t ldr_filtered(void)
{
ADC_ChannelConfTypeDef cfg = {0};
cfg.Channel = LDR_ADC_CH;
cfg.Rank = 1;
cfg.SamplingTime = ADC_SAMPLETIME_71CYCLES_5;
HAL_ADC_ConfigChannel(&hadc1, &cfg);
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 10) != HAL_OK) return 0xFFFF;
uint16_t raw = (uint16_t)HAL_ADC_GetValue(&hadc1);
HAL_ADC_Stop(&hadc1);
ldr_hist[ldr_idx++ % LDR_WIN] = raw;
uint16_t tmp[LDR_WIN];
memcpy(tmp, ldr_hist, sizeof(tmp));
for (uint8_t i = 0; i < LDR_WIN - 1; i++) {
for (uint8_t j = 0; j < LDR_WIN - 1 - i; j++) {
if (tmp[j] > tmp[j + 1]) {
uint16_t s = tmp[j];
tmp[j] = tmp[j + 1];
tmp[j + 1] = s;
}
}
}
uint32_t sum = 0;
for (uint8_t i = 1; i < LDR_WIN - 1; i++) sum += tmp[i];
return (uint16_t)(sum / (LDR_WIN - 2));
}

光敏电阻的输出电压跟照度并不是线性的,它和一个 10 kΩ 固定电阻串起来分压,ADC 采到的其实是分压点:

VADC=VCCRfixRLDR+RfixV_{ADC} = V_{CC} \cdot \frac{R_{fix}}{R_{LDR} + R_{fix}}

所以代码里没有做”照度换算”,只把 ADC 原始值做滤波后直接和阈值比。这是刻意的取舍:换成勒克斯需要标定,而我只需要一个”要不要开灯”的判断

语音命令解析:环形缓冲 + 命令表#

ASR-PRO 识别出命令后,通过串口发一行 CMD:<id>。主控这边不解析语义,只做 ID 到函数的映射。

Core/Src/voice.c
#define RX_RING 64
static uint8_t ring[RX_RING];
static volatile uint16_t w_idx;
/* UART1 中断回调:只搬字节,不做任何解析 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance != USART1) return;
w_idx = (uint16_t)((w_idx + 1) % RX_RING);
HAL_UART_Receive_IT(&huart1, &ring[w_idx], 1);
}
/* 命令表:ID 必须和 ASR-PRO IDE 里词条的排列顺序一一对应 */
typedef struct { uint8_t id; const char *name; void (*handler)(void); } voice_cmd_t;
static void cmd_light_on(void);
static void cmd_light_off(void);
static void cmd_report_env(void);
static const voice_cmd_t g_cmds[] = {
{ 1, "开灯", cmd_light_on },
{ 2, "关灯", cmd_light_off },
{ 3, "现在几度", cmd_report_env },
};
void voice_poll(void)
{
static uint16_t r_idx;
static char line[32];
static uint8_t n;
while (r_idx != w_idx) { /* 环形缓冲还有没读完的字节 */
r_idx = (uint16_t)((r_idx + 1) % RX_RING);
char c = (char)ring[r_idx];
if (c == '\n') { /* 收到完整一帧才动 */
line[n] = '\0';
if (strncmp(line, "CMD:", 4) == 0) {
uint8_t id = (uint8_t)atoi(line + 4);
for (uint8_t i = 0; i < sizeof(g_cmds) / sizeof(g_cmds[0]); i++) {
if (g_cmds[i].id == id) {
g_cmds[i].handler();
n = 0;
return;
}
}
voice_say("没听清,再说一遍"); /* 未识别也要回话 */
}
n = 0;
} else if (c != '\r' && n < sizeof(line) - 1) {
line[n++] = c;
}
}
}
读写指针必须各自取模

上面 ins 标出的两行是这段代码里唯一的”技巧”,也是最容易写错的地方。环形缓冲的两个指针一个在中断里改、一个在主循环里改,两边必须用同一套取模规则。常见的错误是只在其中一侧写 if (x >= N) x = 0 兜底:单看没错,但两侧规则一旦不一致,两个指针就会落在不同区间,r_idx != w_idx 这个判断会开始撒谎——轻则漏读命令,重则读到一段早被覆盖的旧字节。

这个写法还有一个已知局限:它区分不了”空”和”满”(两种情况下 r_idx == w_idx)。所以缓冲区我留了 64 字节,而单条命令最长不过 30 字节,实际上填不满——用一点内存余量换掉了”再维护一个计数器”的那条分支。

MAYA1.0 的表情动画:屏幕上亮起的蓝色像素脸

视觉检测:OpenMV 端的人脸 + 亮度#

视觉这部分我把算力全部留在 OpenMV 板上,STM32 收到的只是结论。

openmv/main.py
# OpenMV Cam M7:人脸检测 + 亮度分析,结果以 JSON 经 UART 上报
import sensor, image, time, json
from machine import UART
sensor.reset()
sensor.set_pixformat(sensor.GRAYSCALE) # 只做人脸和亮度,灰度就够,帧率更高
sensor.set_framesize(sensor.QVGA) # 320x240
sensor.set_auto_gain(True)
sensor.skip_frames(time=2000) # 等自动增益稳定
uart = UART(2, baudrate=115200, timeout_char=100)
# 固件内置的 Haar 级联,不用自己训练,也不占额外 Flash
face_cascade = image.HaarCascade("frontalface", stages=25)
BRIGHT_TH = 60 # 低于这个灰度均值就该补光了
while True:
img = sensor.snapshot()
# 1) 亮度:整幅灰度均值,比光敏电阻更接近「人眼感受」
stats = img.get_statistics()
brightness = int(stats.mean())
# 2) 人脸:threshold 越小越容易误检,0.75 是调出来的平衡点
faces = img.find_features(face_cascade, threshold=0.75, scale_factor=1.25)
if faces:
f = max(faces, key=lambda r: r[2] * r[3]) # 取面积最大的那张脸
img.draw_rectangle(f, color=255, thickness=2)
# 3) 只发关键字段,STM32 端解析负担最小
uart.write((json.dumps({"face": bool(faces),
"brightness": brightness}) + "\n").encode())
if faces and brightness < BRIGHT_TH:
uart.write(b"ANIM:SMILE\n") # 有人 + 光线暗 → 打招呼并准备补光
time.sleep_ms(50) # 约 20 fps,别把串口灌满

stages=25 是这里最关键的一个参数。Haar 级联的级数越多检测越准,但内存占用和耗时也越高;M7 上默认级数跑不满帧率,降到 25 之后检测延迟压到了 100 ms 以内,误检率也没有明显上升。

网络通信:ESP8266 从控端#

从控端的固件短得可以整个贴出来——它的全部工作就是”收到指令 → 拨继电器 → 回执”。

slave/slave.ino
/* 从控端:连主控 AP,监听 UDP,拨继电器并回执 */
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
const char *SSID = "MAYA1.0-AP";
const char *PASS = "maya123456";
const uint16_t PORT = 4210; // 所有从控监听同一端口
const uint8_t RELAY_PIN = 0; // GPIO0 → 继电器
WiFiUDP udp;
char buf[64];
static void reply(const char *msg) {
udp.beginPacket(udp.remoteIP(), udp.remotePort());
udp.print(msg);
udp.endPacket();
}
void setup() {
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, LOW); // 上电默认断开,避免误开
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASS);
while (WiFi.status() != WL_CONNECTED) { delay(200); Serial.print('.'); }
Serial.printf("\nslave up, ip=%s\n", WiFi.localIP().toString().c_str());
udp.begin(PORT);
}
void loop() {
int len = udp.parsePacket();
if (len <= 0) return;
len = udp.read(buf, sizeof(buf) - 1);
if (len <= 0) return;
buf[len] = '\0';
// 统一剥掉尾部换行,避免主控端带 \n 而测试脚本不带
for (int i = 0; i < len; i++)
if (buf[i] == '\r' || buf[i] == '\n') buf[i] = '\0';
if (strcmp(buf, "LIGHT_ON") == 0) {
digitalWrite(RELAY_PIN, HIGH);
reply("OK:LIGHT_ON");
} else if (strcmp(buf, "LIGHT_OFF") == 0) {
digitalWrite(RELAY_PIN, LOW);
reply("OK:LIGHT_OFF");
} else {
reply("ERR:UNKNOWN"); // 不认识的指令一律拒绝,别装成功
}
}

显示驱动:树莓派端的动画播放器#

LCD 上显示的一帧:蓝色像素表情与三个家电状态图标(台灯 / 风扇 / 加湿器)

树莓派侧是一个开机自启的 Python 程序,职责单一到只有三件事:收串口、切动画、没命令时待机。

display/display.py
16 collapsed lines
#!/usr/bin/env python3
"""树莓派 2B 端:监听串口动画命令,用 pygame 播放,40 s 无命令进待机。"""
import json
import os
import serial
import pygame
SERIAL_PORT = "/dev/ttyS0"
BAUD = 115200
IDLE_TIMEOUT = 40 # 秒
FPS = 24
HERE = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(HERE, "anims.json"), encoding="utf-8") as f:
ANIMS = json.load(f) # {"ANIM:IDLE": "assets/idle", "ANIM:SMILE": "assets/smile"}
class Player:
def __init__(self):
pygame.init()
self.screen = pygame.display.set_mode((480, 320), pygame.FULLSCREEN)
self.clock = pygame.time.Clock()
self.frames, self.index, self.name = [], 0, None
self.load("ANIM:IDLE")
def load(self, name):
"""切换动画:载入新序列,缺资源时保持当前画面而不是崩掉。"""
if name not in ANIMS:
print(f"[warn] 未定义的动画 {name!r},保持当前画面")
return
path = os.path.join(HERE, ANIMS[name])
self.frames = sorted(
pygame.image.load(os.path.join(path, f)).convert()
for f in os.listdir(path)
if f.lower().endswith((".png", ".jpg"))
)
if not self.frames:
print(f"[warn] {path} 里没有帧图,忽略")
return
self.index, self.name = 0, name
def run(self):
ser = serial.Serial(SERIAL_PORT, BAUD, timeout=1)
buf = b""
idle = 0.0
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
return
chunk = ser.read(64)
if chunk:
buf += chunk
while b"\n" in buf: # 只认换行结尾的完整帧
line, buf = buf.split(b"\n", 1)
cmd = line.decode("utf-8", "ignore").strip()
if cmd.startswith("ANIM:"):
self.load(cmd)
idle = 0.0
idle += self.clock.tick(FPS) / 1000.0
if idle >= IDLE_TIMEOUT and self.name != "ANIM:IDLE":
self.load("ANIM:IDLE") # 静默太久,回呼吸灯待机
idle = 0.0
if self.frames:
self.screen.blit(self.frames[self.index], (0, 0))
pygame.display.flip()
self.index = (self.index + 1) % len(self.frames)
if __name__ == "__main__":
Player().run()

这里那个 while b"\n" in buf 的写法值得说一下:串口 read(64) 不保证每次都刚好读到一帧,可能读到半截,也可能一次读到两帧半。所以先把数据全丢进缓冲区,再按换行切,而不是假设”一次 read 就是一帧”。这是串口编程里最常见的坑,我在调试阶段被它坑掉了整整一个下午(详见下文)。

编译与烧录#

主控侧走的是标准的 arm-none-eabi-gccst-flash 流程,树莓派侧只需要把脚本挂成开机自启:

编译 → 烧录 → 部署
# 1) 编译 STM32 固件
cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
# 2) 烧录到主控(ST-Link)
st-flash --reset write build/maya.bin 0x8000000
# 3) 树莓派侧部署为开机自启服务
scp -r display/ pi@maya-pi:/opt/maya/
ssh pi@maya-pi 'sudo systemctl enable --now maya-display.service'
构建脚本别塞进 git

build/ 目录和 CMake 生成的缓存文件加起来动辄几十 MB,而且每次全量重建都会变。项目里加一行 build/ 就够了——这条经验是我把仓库推到 200 MB 之后才学会的。

联调与实验结果#

为了验证功能与性能,我设计了四组实验:语音识别、机器视觉、环境监测、家电控制。每组都在不同条件下重复多轮,取稳定值。

测试项测试条件结果是否达标
语音识别安静环境准确率 98%,响应 < 1 s
语音识别空调 / 风扇噪声准确率 > 90%,响应略延长
人脸识别正常光照准确率 95%,识别 < 1 s
人脸识别低光照准确率降至 85%,耗时延长⚠️ 需补光
环境监测温湿度阶跃变化数据精度高,联动响应 < 2 s
环境监测光照阶跃变化及时触发灯光开关
家电控制靠近路由器响应约 0.4 s
家电控制远距 / 弱信号响应延迟上升,仍 < 0.5 s

汇总一下几个关键指标:

  • 语音识别在安静与噪声环境下都稳定在 90% 以上
  • 视觉检测在复杂光照条件下 85% 以上
  • 环境监测与家电联动响应 控制在 2 秒以内
  • 远程控制延迟 小于 0.5 秒
低光照是这套方案的硬伤

人脸识别在低光下掉到 85%,而且这个数字还是”正面、无遮挡”的理想情况。原因在于 OpenMV 用的是普通 CMOS 摄像头,环境照度不足时图像噪声直接淹没了 Haar 特征。当时的应对办法是让视觉结果和光敏电阻互相校验——光敏电阻说暗,就不指望人脸检测出结果,直接走”补光”分支。要真正解决得换红外摄像头,这属于后续迭代范围。

至于长时间运行的稳定性,我做了一轮不间断的老化测试,监控内存占用、网络丢包和看门狗复位次数。整体表现符合预期,唯一暴露出来的是 Wi-Fi 在信号边缘区域偶发掉线——从控端会自动重连,但重连期间的控制命令会丢,这也是后来把控制帧改成 UDP 的原因之一(宁可丢一条过期命令,也不要 TCP 在那里傻等重传)。

产品化:成本、市场与代价#

项目里完整分析了产品化前景,这里只挑三个我真正关心的数字。

成本。 单台样机的核心元件(STM32、语音模块、视觉模块、传感器、电机等)总成本预计在 700~900 元,批量生产后可控制在 500 元以内。这个数字能达到,靠的是核心部件全部选了国产成熟方案——这个价位在智能家居市场里是有竞争力的。

市场。 未来 5 年智能家居与物联网机器人市场的年复合增长率预计超过 20%。更重要的是需求结构在变:不只是大城市高端家庭,中小城市用户也开始关注家居智能升级;养老机构、高端公寓、写字楼物业对”一站式”智能管家的兴趣也在上升。

代价。 这部分我原本写得很委婉,这里说直白一点:这套方案最大的代价是离线语音的封闭性。ASR-PRO 只认预先烧录的命令词,用户想加一句”把客厅灯调暗一点”这种带参数的自然语言,就必须重新走一遍 IDE 流程。而换成在线方案,隐私、延迟和持续成本又都回来了。这是当前阶段无法同时满足的三角。

产品化挑战现象拟采取的方案
视觉受光照影响低光下识别率降到 85%换红外摄像头 / 高灵敏度传感器,升级识别算法
语音噪声鲁棒性多人同时说话时识别率下降加 AI 降噪、阵列麦克风;未来接大模型对话
Wi-Fi 稳定性大户型 / 弱信号下延迟爬升Mesh 组网、MQTT 协议、4G/5G 备用链路
隐私与数据安全用户对本地数据敏感数据本地化存储、端到端加密、权限分级

写在研发过程中#

这个项目目前仍在研发中——它还没到可以拍胸脯说”做完了”的程度。

从方案设计上,我给自己划了三条原则:

  1. 不让机器人替用户做决定,只做推荐。 所以流程里有明确的”确认管理方案”环节,用户可以拒绝、可以改。智能家居最怕的是”自作聪明”,灯自己亮了自己灭,用户反而失去掌控感。
  2. 让每一步都有回执。 执行完要显示状态,环境数据要实时播报。机器人必须让用户始终知道它”现在认为世界是什么样的”。
  3. 本地优先。 语音离线、决策本地,网络只用于最终控制家电。这样即使断网,机器人至少还能聊天、还能监测、还能显示状态。

调试阶段踩过的坑里,有两个到现在还记得很清楚:

第一个是串口分包。 树莓派的动画播放器偶尔会”卡住不切动画”。查了很久才发现:STM32 发的是 ANIM:SMILE\n,但树莓派这边的 ser.read(64) 有时只读到 ANIM:SM,剩下的 ILE\n 要等下一次 read 才到。而当时的代码是”读到什么就解析什么”,于是永远匹配不上映射表。

修法就是前面代码里那个缓冲区 + 按换行切,改动其实只多了一行循环:

display.py —— 修掉串口分包
chunk = ser.read(64)
if chunk:
cmd = chunk.decode("utf-8", "ignore").strip()
if cmd.startswith("ANIM:"):
self.load(cmd)
if chunk:
buf += chunk
while b"\n" in buf: # 只认换行结尾的完整帧
line, buf = buf.split(b"\n", 1)
cmd = line.decode("utf-8", "ignore").strip()
if cmd.startswith("ANIM:"):
self.load(cmd)

第二个是电平匹配。 ASR-PRO 是 3.3 V TTL,我最初把它直接接在一路 5 V 的串口上,结果语音模块时好时坏——不是完全不工作,而是大部分时候正常、偶尔识别错。这种”大部分时候正常”的故障最难查,因为它看起来像软件问题。后来用示波器量才发现电平不对,加了一级电平转换之后就稳了。

接下来要推进的是把上面这些模块真正联调起来,让这条五步闭环从图纸上跑起来,同时把 ANIM 资源从”几张测试图”扩充成一套完整的表情包。

小结#

MAYA1.0 不是要做一个”什么都会”的机器人,而是想把桌面这一平方米管理好:知道环境、听得懂话、能动家电、会跟你商量。

做这个项目最大的体会是:机器人产品化的难点从来不在单点技术,而在闭环。语音识别、视觉、Wi-Fi 每一个都是成熟模块,但把它们串成一条用户愿意反复使用 的链路,需要反复打磨交互的每个停顿——什么时候该主动说话、什么时候该等、拒绝之后怎么接茬。这些东西没有现成模块可以买。

一点私心

比起”完成了多少指标”,我其实更在意有人从桌前走过时,MAYA1.0 自己转过来打了个招呼。技术上那只是一次 find_features() 命中加上一行 ANIM:SMILE,但那一刻它是活的。

其实它第一个认错人了——先冲着我旁边的朋友热情地打了个招呼,才转向我。

MAYA1.0 这个方向我一直在继续推进。不过要先说清楚一件事:下面这个 MAYA3.0 不是本文 MAYA1.0 的升级版,它是另一个独立的项目——两者不共用一套实现,请不要混着看。

MAYA 计划#

这台 MAYA1.0 不是一个孤立的作品,而是「MAYA 计划」的第一代。这个计划一共有三代产品,后续两代会陆续把文章补到这个博客里:

  • 第一代 · MAYA1.0——就是本文这台:STM32 + 树莓派的桌面管家机器人
  • 第三代——待公开

第二代:MAYA3.0(独立项目)#

MAYA3.0 是我在 MAYA1.0 之后另起的一个项目,和本文这套代码、这条技术路线都不是同一个东西。把它和第一代分开列,就是为了避免被当成「新旧两个版本」:

RERELEGEND
/
RA2039-MAYA3.0
Waiting for api.github.com...
00K
0K
0K
Waiting...

相关技术:STM32F103C8T6 · 树莓派 2B · ASR-PRO 离线语音 · OpenMV · ESP8266 · DHT11 / LDR / MPU6050 · SG90 / L298N · LCD 显示

MAYA1.0:物联网管家桌面机器人
https://ura2039.xyz/blog/posts/maya-iot-housekeeper-robot/
作者
LEGEND热热
发布于
2025-03-25
许可协议
CC BY-NC-SA 4.0
评论