MAYA1.0 是一台摆在小桌面上的物联网管家机器人。
它想解决的问题很朴素:家里那些琐碎的事,开灯、关灯、看看屋里闷不闷、有没有人——能不能交给一个摆在桌面上的小家伙,让它用说话的方式跟你商量着办完。
具体来说,它围绕「感知环境 → 给出建议 → 动手执行 → 回显结果」的闭环,把五件事串了起来:
- 语音唤醒——用特定的唤醒词叫醒它(ASR-PRO 离线语音模块)
- 播报环境特征——采集温湿度、光照和自身姿态,念给你听(传感器系统 + 喇叭)
- 确认管理方案——基于环境数据推荐一套家电管理方案,你可以同意,也可以拒绝后自己用语音改(语音模块)
- 执行用户命令——真正去开关家电(ESP8266 Wi-Fi 模块)
- 显示家居状态——LCD 上逐个回显各电器的开关状态(LCD 显示屏)
除了这条”叫它才走”的闭环,它还有几件后台常驻的活儿——持续环境检测、表情与肢体互动、以及基于视觉的自动照明,后面会细说。
先看它跑起来的样子:
关于这段视频俯拍视角一镜到底,记录 MAYA1.0 实机跑动,约 37 秒,无声。
它是什么
它的核心能力可以归成三类:
- 语音聊天——用自然语言对话的方式接收命令、反馈状态
- 环境监测——持续采集周围环境特征,判断”屋里现在是什么状况”
- 温度、湿度(DHT11)
- 光照强度(光敏电阻 + ADC)
- 自身姿态(MPU6050 六轴)
- 智能家居自动管理——根据环境数据推荐家电管理方案,并真正去控制家电
和市面上那些”必须联网、必须连手机 App、开口先转三秒圈”的语音助手不同,MAYA1.0 的定位是离线的、即时的、看得见摸得着的桌面伙伴。它不依赖云端大模型,唤醒词一说就醒,命令一下就走。
一条完整的交互闭环
MAYA1.0 的工作流程不是简单的”听到命令 → 执行”,而是围绕环境感知设计了一个五步闭环:
1. 语音唤醒
通过 ASR-PRO 离线语音模块,用特定的唤醒词进行语音唤醒。唤醒是整条链路的第一道闸门——只有被叫到名字,机器人才开始工作,避免它无时无刻都在”偷听”。
2. 播报环境特征
被唤醒后,主控芯片通过传感器系统接收环境特征数据进行处理,并用喇叭播报当前的环境特征。比如”现在屋内温度 26 度,光照偏暗”——先把它看到的告诉你。
3. 确认管理方案
基于环境特征数据,机器人会主动推荐相应的家用电器管理方案。这里的关键是”商量”而不是”独断”:
- 用户可以拒绝方案,并用语音命令自定义电器的开关状态
- 也可以同意原方案直接执行
4. 执行用户命令
通过 ESP8266 WiFi 模块,实现对家用电器的远程控制。这是”物联网”三个字落地的地方——机器人本体只是个大脑,真正的执行器在墙上插座里。
5. 显示家居状态
用户命令执行成功后,树莓派驱动的 LCD 显示屏上显示各个家用电器的开关状态。让每一次操作都有明确回执,用户不用猜”到底成功没有”。
闭环之外,还有三个让我比较得意的设计:
- 持续环境检测——环境数据不是问一次采一次,而是持续滚动更新,机器人始终知道屋里现在什么样
- 表情与肢体互动——LCD 屏上展示各种有趣的表情,并相应改变机械结构,做出生动的人机互动
- 基于视觉的自动照明——通过机器视觉系统识别的人体数据,在特定时间调整灯光的自动照明方案(比如识别到有人进房间才亮灯,而不是靠定时器瞎猜)
它内部其实是一台状态机
上面这五步听起来像一条直线,但真跑起来并不是”一步一步往下走”——用户随时可能插话、传感器随时可能报警、网络随时可能掉线。所以主控里跑的其实是一个四态状态机,配合一个 50 ms 的心跳节拍。

| 状态 | 触发条件 | 行为 |
|---|---|---|
ST_IDLE | 上电 / 40 s 无操作 | 只等唤醒词,LCD 播待机呼吸动画 |
ST_ACTIVE | 唤醒词命中 | 采集环境、分发命令、处理视觉与网络事件 |
ST_ALARM | MPU6050 检测到倾倒 | 播报警动画,暂停执行动作 |
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 的串口连着,协议简单到就是几行文本,反而最不容易出问题。
硬件选型
整台机器人由四个核心模块 + 一块显示屏构成:

(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,用来实时监测电量、做欠压保护。

(2)ASR-PRO 离线语音模块——耳朵和嘴
ASR-PRO 是天问 block 公司发布的离线语音模块,可以用 C++ 和电脑端 IDE 的图形化编程方式来开发。用于识别人的唤醒命令和其他声音命令,保证机器人和用户的有效互动。
“离线”这两个字是重点:
| 离线语音(MAYA1.0 采用) | 在线语音 | |
|---|---|---|
| 延迟 | 毫秒级 | 通常 1 秒以上 |
| 网络 | 不需要 | 断网即瘫 |
| 隐私 | 音频不出设备 | 上传云端 |
| 成本 | 一颗模块 | 持续调用费用 |
代价是它只认识你预先定义好的一批命令词。但对管家机器人这个场景来说,开灯 / 关灯 / 现在几度这类封闭指令集恰恰是最需要的部分。
硬件上它通过 3.3 V TTL 电平的 UART 跟 STM32 通信,波特率 115200;前端是一颗带降噪的 MEMS 数字麦克风,电源上加了一级 LC 滤波专门滤掉 50 Hz 的电网干扰。

(3)OpenMV 机器视觉模块——眼睛
基于 STM32 微处理器的开源视觉识别模块,可用电脑端 IDE 上的 MicroPython 编程。MAYA1.0 用的是 OpenMV Cam M7:1/4″ CMOS 摄像头加一颗 STM32F7 内核,能在 640×480 下跑到 30 fps,板上就把基础图像算法算完,只把结果吐出来。
它的价值在于把”图像采集 + 算法处理 + 结果输出”封装成一块可以直接读结果的模块:你不用去啃摄像头驱动的时序,直接 find_blobs()、find_features() 就能拿到目标坐标。MAYA1.0 主要用它做两件事:
- 人脸检测——识别到主人来了就触发个性化问候
- 环境亮度分析——跟光敏电阻互为补充,极端光照下也能判断环境
结果通过 UART 以 JSON 形式上报,简单到就是 {"face":true} 或 {"brightness":75} 这种程度。

(4)ESP8266 WiFi 通讯模块——连上世界
市面上常用的一种低成本 Wi-Fi 微控制器,内置一颗 32 位 Tensilica L106,主频 80 MHz,带完整的 TCP/IP 协议栈。板载天线加低功耗睡眠,非常适合嵌入式物联网场景。
严格说 ESP8266 自己就是一颗能独立跑程序的主控,MAYA1.0 这里把它当通信协处理器用——STM32 负责决策,ESP8266 负责把决策变成网络请求发出去控制家电。这种分工让主控不必负担 TCP/IP 协议栈的开销。

(5)树莓派 2B + 3.5 寸 LCD——脸
显示模块采用 3.5 寸 LCD 显示屏,配合树莓派 2B 驱动,可显示文字、图形、动画和机器人互动表情,清晰直观地呈现环境监测数据、家电控制状态及人机交互界面。
树莓派这一侧实际上只有一个开机自启的 Python 程序:监听串口、解析命令、用 pygame 播放动画。命令帧简单到就是把名字念一遍——ANIM:SMILE、ANIM:SAD、ANIM:IDLE。这种”简陋”的协议是故意的:两边各自独立开发,只要串口对上就能联调。

(6)SG90 舵机与直流电机——手和脚
机器人结构动作和转向控制选用 SG90 舵机,STM32 输出 50 Hz 的 PWM 信号控制 0°–180° 角度,用来做头部细微动作。舵机电源线上加了一级 RC 抑制电路,降低开关噪声对主控的干扰。
底盘则是两台 9 V 直流电机驱动履带,通过 L298N 双 H 桥实现双向调速;STM32 用 PWM 占空比控制速度,电机轴上的光电编码器把实际转速反馈回来,为后续的精确定位和路径规划留好接口。

(7)机械结构与外观——3D 打印外壳 + 积木骨架
整机外观走的是机甲风格,外壳用 PLA 材料 3D 打印——既能支持复杂曲面和棱角,强度也够。建模在 SolidWorks 里完成:先画各外壳零件的草图,用拉伸、扫掠、倒角做出主体形状,再导出 STL 交给打印机。
骨架部分则是三百片模块化积木拼出来的。每个模块按标准化接口设计,既能快速装配,又能灵活调整布局去适配不同传感器、舵机和电机的位置。这个设计本质上是为迭代服务的——第一版装配完发现舵机线会干涉,改几个卡扣形状就能重来,不用重新打印整块外壳。经过多次迭代,我优化了模块连接的卡扣形状和定位销孔,保证结构在承载动力输出、反复运动之后不松动、不位移。

一张自己组的局域网:四颗 ESP8266
家用 Wi-Fi 往往是整个系统里最不可控的一环——路由器品牌各异、信号覆盖不均、还可能根本没有网。所以在网络设计上 MAYA1.0 干脆不依赖家里的路由器,而是自组了一个小型局域网。
方案是一主三从:
- 主控端 A 配置为 AP 模式,自己开热点,供从控端接入
- 从控端 B / C / D 连上 A,各自监听同一个 UDP 端口
- STM32 通过 UART 把控制帧交给 A,A 再广播出去
指令格式简单到可以直接在纸上推演:
| 指令 | 方向 | 作用 |
|---|---|---|
LIGHT_ON | A → B/C/D | 对应从控 GPIO0 拉高,继电器吸合 |
LIGHT_OFF | A → B/C/D | 从控 GPIO0 拉低,继电器断开 |
OK:LIGHT_ON | 从控 → A | 执行回执,主控据此更新 LCD |
ERR:UNKNOWN | 从控 → A | 未知指令,拒绝执行 |
为什么不走 TCP我的第一版设计里写的是”TCP/UDP 混合”,但实际调试时我把控制帧全改成了 UDP。原因很直接:这种指令是幂等的且过期就该丢。
LIGHT_ON晚到 3 秒送达,效果和立刻送到完全一样;但 TCP 的自动重传会让一条已经过期的命令在后面某个时刻突然生效——用户早就走开了,灯突然亮起来,比丢包更糟。UDP 的”不可靠”在这个场景里反而是一种保护。代价是需要自己做应用层确认,也就是表里那两条回执帧。
代码分析
下面是我在调试过程中真正跑通的几段关键实现。整个项目按模块拆成了 6 个部分:主循环调度、环境采集、语音解析、视觉检测、网络通信、显示驱动。
TIP如果你想复现一套类似的系统,先把”STM32 发一行字符串 → 树莓派原样打印出来”这条最小链路跑通,再去接传感器和语音。我一开始跳过了这步直接上全套,第一次联调时十几个模块同时报错,根本不知道从哪一条查起。
主循环:心跳 + 事件队列 + 状态机
主控的核心是不阻塞。任何一件事都不允许在主循环里等待——否则语音命令会被传感器采样卡住,或者反过来。所以所有子程序都是”有结果就置一个事件位”,主循环只负责按优先级消费。
/* 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 的心跳只是”去问一声”,真正拿到新数据其实是一秒一次。
/* 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 采到的其实是分压点:
所以代码里没有做”照度换算”,只把 ADC 原始值做滤波后直接和阈值比。这是刻意的取舍:换成勒克斯需要标定,而我只需要一个”要不要开灯”的判断。
语音命令解析:环形缓冲 + 命令表
ASR-PRO 识别出命令后,通过串口发一行 CMD:<id>。主控这边不解析语义,只做 ID 到函数的映射。
#define RX_RING 64static 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 字节,实际上填不满——用一点内存余量换掉了”再维护一个计数器”的那条分支。
视觉检测:OpenMV 端的人脸 + 亮度
视觉这部分我把算力全部留在 OpenMV 板上,STM32 收到的只是结论。
# OpenMV Cam M7:人脸检测 + 亮度分析,结果以 JSON 经 UART 上报import sensor, image, time, jsonfrom machine import UART
sensor.reset()sensor.set_pixformat(sensor.GRAYSCALE) # 只做人脸和亮度,灰度就够,帧率更高sensor.set_framesize(sensor.QVGA) # 320x240sensor.set_auto_gain(True)sensor.skip_frames(time=2000) # 等自动增益稳定
uart = UART(2, baudrate=115200, timeout_char=100)
# 固件内置的 Haar 级联,不用自己训练,也不占额外 Flashface_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 从控端
从控端的固件短得可以整个贴出来——它的全部工作就是”收到指令 → 拨继电器 → 回执”。
/* 从控端:连主控 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"); // 不认识的指令一律拒绝,别装成功 }}显示驱动:树莓派端的动画播放器

树莓派侧是一个开机自启的 Python 程序,职责单一到只有三件事:收串口、切动画、没命令时待机。
16 collapsed lines
#!/usr/bin/env python3"""树莓派 2B 端:监听串口动画命令,用 pygame 播放,40 s 无命令进待机。"""import jsonimport osimport serialimport pygame
SERIAL_PORT = "/dev/ttyS0"BAUD = 115200IDLE_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-gcc 加 st-flash 流程,树莓派侧只需要把脚本挂成开机自启:
# 1) 编译 STM32 固件cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DCMAKE_BUILD_TYPE=Releasecmake --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 备用链路 |
| 隐私与数据安全 | 用户对本地数据敏感 | 数据本地化存储、端到端加密、权限分级 |
写在研发过程中
这个项目目前仍在研发中——它还没到可以拍胸脯说”做完了”的程度。
从方案设计上,我给自己划了三条原则:
- 不让机器人替用户做决定,只做推荐。 所以流程里有明确的”确认管理方案”环节,用户可以拒绝、可以改。智能家居最怕的是”自作聪明”,灯自己亮了自己灭,用户反而失去掌控感。
- 让每一步都有回执。 执行完要显示状态,环境数据要实时播报。机器人必须让用户始终知道它”现在认为世界是什么样的”。
- 本地优先。 语音离线、决策本地,网络只用于最终控制家电。这样即使断网,机器人至少还能聊天、还能监测、还能显示状态。
调试阶段踩过的坑里,有两个到现在还记得很清楚:
第一个是串口分包。 树莓派的动画播放器偶尔会”卡住不切动画”。查了很久才发现:STM32 发的是 ANIM:SMILE\n,但树莓派这边的 ser.read(64) 有时只读到 ANIM:SM,剩下的 ILE\n 要等下一次 read 才到。而当时的代码是”读到什么就解析什么”,于是永远匹配不上映射表。
修法就是前面代码里那个缓冲区 + 按换行切,改动其实只多了一行循环:
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 之后另起的一个项目,和本文这套代码、这条技术路线都不是同一个东西。把它和第一代分开列,就是为了避免被当成「新旧两个版本」:
相关技术:STM32F103C8T6 · 树莓派 2B · ASR-PRO 离线语音 · OpenMV · ESP8266 · DHT11 / LDR / MPU6050 · SG90 / L298N · LCD 显示








































