
简介这是一套面向STM32、树莓派与微信小程序端的智能家居控制系统完整工程后端采用Java SpringBoot架构覆盖嵌入式设备、服务端接口与用户控制端三大层面适合毕业设计、课程设计、工程实训及学科竞赛等场景。包内共1472个文件包含C/C与Java源码、硬件工程配置、Python辅助脚本、JSON/XML配置、前端小程序页面及说明文档等压缩包约62.72MB便于对照目录结构快速定位与二次开发。项目代码经严格测试运行功能完整可复现复刻也可借鉴设计思路或扩展更多智能家居功能。当前已有46人学习浏览适合具备一定嵌入式或Web开发基础、希望快速搭建完整项目的学习者参考使用。1. 说句实话这套系统最难的不是「写代码」而是让四层之间不扯皮智能家居控制系统的毕设常见套路是 STM32 负责采集和环境控制微信小程序负责展示背后再接一个 Java 的 Spring Boot 后端。但真正动手时卡住的地方往往不在驱动或页面而在链路本身串口协议定得不对、上报接口没有历史数据、小程序一退后台就收不到设备状态变化。STM32、树莓派、Java 的 Spring Boot 架构和微信小程序四个组件单独都有成熟教程放在一起却要解决两个核心问题数据从单片机到手机经过哪几跳、每跳用什么格式控制指令从用户点下开关到 GPIO 翻转要多长时间、失败丢在哪一步。下面按「链路设计 → 嵌入式端 → 网关与后端 → 小程序 → 联调」的顺序把一套能跑通的方案拆开讲毕设、课设、实训和竞赛都能直接抄升级到真实物联网平台的思路在最后一章点一下。2. 技术选型与链路设计为什么是 STM32、树莓派、Spring Boot 和小程序四层2.1 四层架构各自的职责边界先把各层该干什么定死联调时才能快速定位责任方层级承担设备核心职责常见开发环境/语言感知执行层STM32F103C8T6 最常见GPIO 采温湿度、继电器开关、PWM 调光、解析串口指令KEIL MDK / C边缘网关层树莓派 4B通过 USB 转串口读取 STM32 上报与后端双向通信Python3 / pyserial业务后台层云服务器或本机服务器设备数据入库、用户与设备权限、指令下发、WebSocket 推送Java Spring Boot用户触达层微信小程序登录、仪表盘、开关控制、设备状态实时展示微信开发者工具最容易被问倒的问题是为什么中间要放一个 Spring Boot 后端而不是让树莓派直连小程序原因有两层。其一微信小程序对wx.request和wx.connectSocket有域名校验本地树莓派 IP 不能被配置成合法域名线下演示时开发者工具可以勾选「不校验合法域名」但真机预览就会被卡住。其二树莓派通常在家里或实验室的 NAT 后面没有公网入口小程序想主动连它基本不现实。把 Spring Boot 部署到有公网访问能力的服务器上小程序只和 HTTPS 域名通信树莓派作为客户端主动维持长连接整个拓扑才是可长时间运行的。2.2 两条数据通道的设计上行遥测与下行控制我这套系统里最省心的做法是让上行和下行走不同的协议通道避免一个地方故障把两条链路同时拖死。上行链路是 STM32 到小程序的方向。STM32 通过串口按 1Hz 到 2Hz 发送#TEMP:25.3,HUMI:60.1,LED:1\r\n这种行协议树莓派网关程序解析成 JSON 后用 HTTP POST 提交给 Spring Boot 的/api/device/report接口。后端把数据写入 MySQL 和 Redis 缓存再通过 WebSocket 把同一份 JSON 推送向在线的小程序页面。下行链路是用户操作到 GPIO 的方向。小程序调用POST /api/device/controlSpring Boot 做完鉴权和设备校验后把指令通过 Redis 发布订阅推给树莓派网关树莓派把#LED:1\r\n写到串口STM32 收到后翻转引脚并回一个#LED:ACK\r\n树莓派再把执行结果回调给后端状态最终由后端统一维护。这样设计的好处是每层只需要认识一种协议STM32 只处理串口 ASCII 行树莓派只做收发和格式转换后端只看到 REST 请求和 WebSocket 消息小程序不需要关心底层硬件。排错时先看哪一跳的数据格式不对直接定位到具体模块不用把整条链路从头查到尾。2.3 为什么不用 MQTT 而用「HTTP WebSocket」组合很多竞赛项目会直接引入 MQTT broker让 STM32、树莓派、后端都订阅同一主题。MQTT 在真实生产环境是更优解但如果这是毕设或实训我一般建议先别给题目加复杂度引入 EMQX、配置发布订阅、处理 QoS 和遗嘱消息每一个都会占掉一整个晚上的联调时间。而 Spring Boot 对spring-boot-starter-websocket的支持非常简洁小程序原生支持wx.connectSocket两端事件模型能直接对上。先跑通 HTTP WebSocket 这条链路再去替换协议整套系统的骨架也不会白做。就算评委在答辩时问「为什么不用 MQTT」你也能说出两条对比结论一是设备量在几十台以内时 HTTP 单次请求的开销可以接受二是 WebSocket 能让小程序端像订阅事件一样接收状态变更开发成本比维护一套 MQTT over WebSocket 的桥接低得多。等到需要接入更多品牌设备、需要离线消息和遗嘱机制时再把树莓派网关换成 MQTT Client后端保留现有 REST 接口改动可控。2.4 数据库表设计一张设备表加一张上报日志表数据库结构在写后端之前就定死能省掉后面联调时改字段的麻烦CREATE TABLE device ( id int(11) NOT NULL AUTO_INCREMENT, device_key varchar(32) NOT NULL COMMENT 设备唯一标识如 room1_dht, device_name varchar(64) DEFAULT NULL COMMENT 展示名称, type tinyint(4) DEFAULT 0 COMMENT 0-传感器 1-开关 2-调光, last_status varchar(128) DEFAULT NULL COMMENT 最新状态JSON, last_report_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_dev_key (device_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE device_report_log ( id bigint(20) NOT NULL AUTO_INCREMENT, device_id int(11) NOT NULL, temp decimal(5,2) DEFAULT NULL, humi decimal(5,2) DEFAULT NULL, status_json varchar(512) DEFAULT NULL COMMENT 扩展字段存开关等状态, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_device_time (device_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;device_report_log专门留给趋势曲线和历史查询last_status只存最新值画仪表盘时直接读device表不需要ORDER BY create_time DESC LIMIT 1这种性能不稳定的查询。status_json字段保持一个通用 JSON等以后加窗帘、空调等设备时不用改表结构。3. STM32 端的数据采集与控制执行串口协议写严后面联调才不遭罪3.1 最小硬件连接与工程初始化在 STM32F103C8T6 这颗最常见的芯片上我用 USART1 做调试日志输出USART2 通过 PA2TX/ PA3RX接到外部 USB 转 TTL 模块树莓派侧再插一个 USB 转串口。DHT11 温湿度传感器接 PB12继电器控制引脚用 PB13。接线时有个隐藏坑DHT11 的数据线需要外接 4.7kΩ 上拉电阻否则读到的数据会偶发跳变继电器模块要确认是高电平触发还是低电平触发直接决定引脚初始化时的默认电平接反了会出现「上电就吸合」的灵异现象。KEIL MDK 工程配置里时钟树建议用 8MHz 无源晶振加 PLL 倍频到 72MHz。如果换成 HSI 内部时钟串口波特率会有偏差和树莓派对接 115200 时偶发乱码。很多人会搜「stm32 晶振电容计算」其实 F103 的负载电容按 20pF 左右配对即可关键是 HSE 起振后在SystemClock_Config里确认HAL_RCC_ClockConfig返回成功别让芯片跑在异常频率下。串口接收不要在主循环里用阻塞方式等字节。打开 USART2 的 RXNE 中断每收到一个字节就存入一个环形缓冲区主循环只负责检查缓冲区有没有完整的一行#include stm32f1xx_hal.h #include stdio.h #include string.h extern UART_HandleTypeDef huart2; uint8_t rx_buf[128]; volatile uint16_t rx_len 0; void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_RXNE)) { rx_buf[rx_len] (uint8_t)(huart2.Instance-DR 0x1FF); if (rx_len sizeof(rx_buf)) { rx_len 0; } } } void Device_ReportOnce(void) { char line[64]; // 取当前继电器状态拼成 ASCII 行协议 uint8_t led_state HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_13); snprintf(line, sizeof(line), #TEMP:%.1f,HUMI:%.1f,LED:%d\r\n, g_dht.temperature, g_dht.humidity, led_state); HAL_UART_Transmit(huart2, (uint8_t *)line, strlen(line), 100); }代码逻辑说明snprintf把温度和湿度格式化成一位小数LED字段直接读取 PB13 当前电平。协议固定用#开头、逗号分隔、\r\n结尾是因为中间隔着树莓派调试时用screen或minicom连上串口人眼能直接读出TEMP:25.3,HUMI:60.1,LED:1比二进制结构体好排查得多。HAL_UART_Transmit的最后一个参数是超时时间 100ms工程里如果启用了 RTOS这里可以换成带信号量的发送接口保证多任务下不互相打断。如果 DHT11 某次读取失败也要把上一次缓存的数据继续上报让后端至少能看到设备在线。只上报变化值会引入「无变化不上报」的额外判断毕设阶段没有必要固定周期上报反而简单可靠。3.2 下行指令解析收到#LED:1再动作并回 ACK下行协议与上行保持一致的风格#LED:0\r\n代表关闭#LED:1\r\n代表打开。收到指令后只执行以#开头且字段名匹配的完整行避免噪声数据触发误动作void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance ! USART2) { return; } if (rx_len 2 rx_buf[rx_len - 2] \r rx_buf[rx_len - 1] \n) { rx_buf[rx_len] \0; if (strncmp((char *)rx_buf, #LED:, 5) 0) { uint8_t val (rx_buf[5] 1) ? GPIO_PIN_SET : GPIO_PIN_RESET; HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, val); char ack[] #LED:ACK\r\n; HAL_UART_Transmit(huart2, (uint8_t *)ack, strlen(ack), 100); } rx_len 0; } }严格来说串口中断里做字符串比较和 GPIO 翻转不是最佳实践但这个量级的操作耗时极短毕设工程里可以接受。更规范的做法是只在这里设置cmd_pending标志位主循环轮询时再执行指令避免长指令挤占中断处理时间。回调里直接回 ACK 的好处是快树莓派端不用额外等待响应延迟能控制在几毫秒内。注意 HAL 库的坑HAL_UART_Receive是阻塞接收不能直接放在回调里等完整一帧。我用的是HAL_UARTEx_ReceiveToIdle配合回调或者直接裸操作huart2.Instance-DR读寄存器。如果用中断方式记得使能UART_IT_RXNE而不是只调用一次HAL_UART_Receive_IT就完事。3.3 上报周期、看门狗与掉线判断上报周期设 1 秒最合适。DHT11 本身分辨率就是秒级100ms 上报除了给数据库制造无意义写入没有实际收益。115200 波特率下一帧 30 字节大约只需要 2.6ms链路瓶颈根本不在串口。树莓派端超时判活设为 5 秒连续 5 秒没收到有效帧就认为 STM32 离线后端据此更新设备在线状态。STM32 端务必开独立看门狗 IWDG溢出周期设 4 秒主循环末尾喂狗。这样 DHT11 偶发卡在拉低时序导致任务阻塞时系统会自动重启而不是让树莓派误判成永久离线。后端在处理掉线告警时用 Redis 记录最近活跃时间3 分钟内重复掉线只告警一次避免围绕电机抖动、网络闪断刷屏。4. 树莓派网关与 Spring Boot 后端把串口数据变成 API4.1 树莓派上的 Python 网关pyserial 读串口requests 上报树莓派端我是用 Python3 写的核心依赖只有pyserial和requests。安装前建议先执行sudo apt update如果下载慢就先把软件源换成国内镜像这是树莓派 4B 上 Python 环境配置的老生常谈。STM32 通过 USB 转 TTL 接到树莓派 USB 口后设备节点一般是/dev/ttyUSB0可以用ls /dev/ttyUSB*确认。import serial import requests import time SERIAL_PORT /dev/ttyUSB0 BAUD_RATE 115200 BACKEND_URL http://your-springboot-host:8080/api/device/report DEVICE_KEY room1_dht def parse_line(line: str): # 示例#TEMP:25.3,HUMI:60.1,LED:1 if not line.startswith(#): return None segments line.strip()[1:].split(,) data {device_key: DEVICE_KEY} for seg in segments: k, v seg.split(:) key k.lower() if key in (temp, humi): data[key] float(v) elif key led: data[led] int(v) return data def main(): with serial.Serial(SERIAL_PORT, BAUD_RATE, timeout2) as ser: while True: line ser.readline().decode(utf-8, errorsignore).strip() if not line: continue payload parse_line(line) if payload: try: resp requests.post(BACKEND_URL, jsonpayload, timeout3) print(resp.status_code, payload) except requests.exceptions.RequestException as e: print([report error], e) time.sleep(0.05) if __name__ __main__: main()代码逻辑说明ser.readline()会一直读到换行符timeout2保证没有数据时最多阻塞 2 秒不会死等。STM32 发来的\r\n经过strip()去掉尾部空白parse_line再用#和逗号切片。半包或者乱码行不以#开头直接返回None跳过下一帧完整数据到达后自然恢复。requests.post设置 3 秒超时后端暂时不可用时网关不会卡死。异常只打印日志继续读下一帧。这个设计隐含了一个重连机制后端恢复后下一帧上报自然成功不需要额外写断线重连逻辑。有个容易忽略的点树莓派如果用自带 UARTGPIO14/15而不是 USB 转串口需要修改/boot/config.txt开启enable_uart1并且停掉系统串口控制台服务否则/dev/serial0会被系统占用。USB 方案没有这些麻烦所以我建议毕设优先用 USB 转 TTL 模块。4.2 Spring Boot 后端上报接收、指令下发与异步落库后端我用 Spring Boot 单体应用启动类加EnableAsync让上报日志异步写入。用 MyBatis-Plus 还是 Spring Data JPA 都行关键是接口方法职责单一下面这个 Controller 足够说明设计RestController RequestMapping(/api/device) public class DeviceController { Resource private DeviceService deviceService; Resource private WsSessionManager sessionManager; PostMapping(/report) public ResultVoid report(RequestBody ReportDTO dto) { deviceService.updateStatus(dto); deviceService.saveReportLogAsync(dto); sessionManager.broadcast(device: dto.getDeviceKey(), JSON.toJSONString(dto)); return Result.ok(); } PostMapping(/control) public ResultVoid control(RequestBody ControlDTO dto) { boolean success deviceService.sendCommandToGateway(dto); return success ? Result.ok() : Result.error(500, gateway offline); } }updateStatus负责更新device.last_status和last_report_timesaveReportLogAsync用Async(reportExecutor)隔离线程池避免上报接口被 MySQL 慢查询拖住。假设 50 台设备每 2 秒上报一帧每秒也就 25 次写入一个专用线程池配 2 个核心线程足够。控制指令下发我用的是「Redis 发布订阅」Spring Boot 把{deviceKey, cmd, value}写入cmd:queue频道树莓派网关线程订阅该频道后写串口。网关与后端之间不需要保持 HTTP 轮询指令延迟在毫秒级而且树莓派在不同网段时也不需要暴露任何端口。如果想省掉 Redis可以让树莓派每秒轮询一次待执行指令表但那样开关灯会有肉眼可见的延迟不如发布订阅干净。4.3 WebSocket 服务端会话池、广播与失效清理WebSocket 部分用spring-boot-starter-websocket注册一个TextWebHandler维护在线会话Component public class DeviceWebSocketHandler extends TextWebHandler { private static final MapString, WebSocketSession SESSIONS new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) { String userId session.getAttributes().get(userId).toString(); SESSIONS.put(userId, session); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { SESSIONS.values().remove(session); } public void broadcast(String userId, String message) { WebSocketSession session SESSIONS.get(userId); if (session ! null session.isOpen()) { try { session.sendMessage(new TextMessage(message)); } catch (Exception e) { SESSIONS.remove(userId); } } } }会话池必须考虑失效连接。sendMessage在手机切后台或网络切换时会抛异常不清理的话每次广播都会撞到一个坏 session。上面的broadcast方法捕获异常后顺手移除防止内存泄漏。握手鉴权通过HandshakeInterceptor校验 URL 上的 tokentoken 由小程序的wx.login流程换取后端用 Redis 维护 token 与 userId 的映射。4.4 提前设好 Spring Boot 配置文件里的 3 个业务参数有些参数不提前设联调时会浪费大量时间spring: datasource: url: jdbc:mysql://localhost:3306/smarthome?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai hikari: maximum-pool-size: 10 connection-timeout: 30000 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 server: port: 8080 tomcat: max-threads: 200 accept-count: 200参数说明maximum-pool-size: 10对几十台设备的场景足够调大反而浪费数据库连接serverTimezoneAsia/Shanghai必须和 Jackson 的时区一致否则小程序拿到的上报时间会差 8 小时这是答辩现场最容易被问出破绽的地方。server.tomcat.accept-count默认只有 100演示时如果评委手机和小程序同时在刷一瞬间的并发超过 100 就会被拒绝改成 200 能让突发流量排队而不是直接报错。5. 微信小程序控制端接口封装、WebSocket 与开关状态一致性5.1 小程序的请求封装与合法域名配置小程序原生项目的核心目录只有pages、utils、app.js三块。app.js的globalData里维护一个backendBaseUrl所有请求和 WebSocket 连接都从这里取。开发阶段可以填局域网 IP 或云服务器 IP但要注意wx.request在真机上强制要求 HTTPS 域名ws://协议不配合法域名也连不上。开发者工具里勾选「不校验合法域名」只能解决本地预览真机预览前一定要在微信公众平台把 HTTPS 域名加到 request 和 socket 合法域名列表里。一个够用的请求封装就一个 Promiseconst BASE_URL getApp().globalData.backendBaseUrl; function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${path}, method, data, header: { Content-Type: application/json }, timeout: 5000, success: (res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data); } else { reject(res.data); } }, fail: (err) reject(err) }); }); } module.exports { request };所有页面const { request } require(../../utils/request)后就能用async/await调接口。后端统一返回{code: 0, data: ..., msg: ok}Promise 只 resolve 业务数据错误统一走 reject页面的try/catch只处理一种失败形态不会每写一个接口就复制一套状态码判断。5.2 仪表盘页面启动时拉一次之后靠 WebSocket 增量更新设备状态页的实时性有两种实现路径每 2 秒轮询一次设备最新状态或者启动时拉一次 WebSocket 推送。轮询代码量更少但会造成不必要的无效请求而且开关被远程改动时页面最多滞后 2 秒。我在实训项目里更推荐后者能撑起「实时控制」这个亮点Page({ data: { temperature: --, humidity: --, ledOn: false }, onLoad() { this.loadDeviceStatus(); this.connectWebSocket(); }, onUnload() { if (this.socketTask) { this.socketTask.close({ code: 1000 }); } }, async loadDeviceStatus() { const data await request(/api/device/room1, GET); this.setData({ temperature: data.temp, humidity: data.humi, ledOn: data.led 1 }); }, connectWebSocket() { const token wx.getStorageSync(token); this.socketTask wx.connectSocket({ url: wss://your-backend-host:8080/ws?token${token} }); this.socketTask.onMessage((res) { const msg JSON.parse(res.data); if (msg.deviceKey room1) { this.setData({ temperature: msg.temp, humidity: msg.humi, ledOn: msg.led 1 }); } }); this.socketTask.onError(() { setTimeout(() this.connectWebSocket(), 3000); }); }, toggleLed(e) { const val e.detail.value ? 1 : 0; request(/api/device/control, POST, { deviceKey: room1, cmd: led, value: val }).catch(() { wx.showToast({ title: 指令发送失败, icon: none }); }); } });关于开关状态的一致性我刻意没有在toggleLed里立即翻转ledOn。原因很简单如果点了开关后端没送达或者 STM32 执行失败页面先画成 ON下一次 WebSocket 推送又跳回 OFF用户会认为系统不稳定。先等指令执行结果通过 WebSocket 回推再更新 UI虽然按钮少了「立刻响应」的手感但状态绝对可信。如果评委纠结这一点你可以补一句「生产级 App 会用乐观更新加超时回滚」这就是很好的加分话术。5.3 多用户与场景模式从「能跑」到「能答辩」的进阶点如果还想让项目更像产品可以在设备表加owner字段把wx.login换来的 openid 绑定到设备后端在control接口里校验当前用户是否有操作权限。场景模式则可以抽象成「设备 动作」的列表小程序发{scene: home, actions: [{deviceKey: led, value: 1}, {deviceKey: ac, value: 1}]}后端逐条执行。这里不要一次性把所有指令并发下发因为 STM32 串口是单通道多设备同时动作时指令会排队场景执行结果要等所有 ACK 都回来再提示用户。6. 联调验证从 STM32 串口帧到 Spring Boot 接口的 3 个高频坑与一条 SQL 曲线6.1 三段式联调先看串口再看接口最后看推送联调的第一现场是树莓派终端执行screen /dev/ttyUSB0 115200能看到#TEMP:25.3,HUMI:60.1,LED:1一行行滚动说明 STM32 到树莓派这段物理链路通了。如果屏幕无输出优先检查 USB 转串口驱动和 TX/RX 是否交叉以及两端是否共地杜邦线超过 20 厘米时高速率下偶发乱码是正常现象可以先降到 9600 波特率验证通路再回到 115200 验收。串口链路正常后用 curl 模拟树莓派网关直接测后端不依赖任何硬件curl -X POST http://localhost:8080/api/device/report \ -H Content-Type: application/json \ -d {deviceKey:room1,temp:26.1,humi:58.0,led:1}返回{code:0}后查日志表确认落库mysql -e select * from device_report_log order by id desc limit 5; smarthome。这个 curl 还能在硬件还没到的时候先把小程序页面和后端所有功能跑起来非常值得养成习惯。6.2 Spring Boot 三个高频问题的快速定位java.net.BindException: Address already in use说明上一次进程没退干净lsof -i:8080找到 PID 杀掉即可Communications link failure是 MySQL 的wait_timeout默认 8 小时回收了空闲连接Hikari 连接池需要探活在配置里加connection-test-query: SELECT 1能解决WebSocket 握手出现 403 时优先查HandshakeInterceptor里对 token 的校验逻辑而不是怀疑跨域小程序 WebSocket 不受浏览器同源策略限制这个 403 绝大多数是业务拦截器返回的。如果小程序端想确认连接细节可以在开发者工具的 Network 面板看 WS 帧不必额外抓包。6.3 一条 SQL 画出 24 小时温湿度曲线让答辩更有说服力的技巧是把上报链路沉淀成可视化数据。按小时聚合最近 24 小时的平均温湿度SELECT DATE_FORMAT(create_time, %H:00) AS hour_point, ROUND(AVG(temp), 1) AS avg_temp, ROUND(AVG(humi), 1) AS avg_humi FROM device_report_log WHERE device_id 1 AND create_time DATE_SUB(NOW(), INTERVAL 24 HOUR) GROUP BY DATE_FORMAT(create_time, %Y-%m-%d %H:00) ORDER BY hour_point;小程序拿到结果后用 canvas 画折线图。这条 SQL 的价值在于同时验证了上报链路的连续性如果设备中途掉线曲线会出现断档比任何日志都直观。注意AVG在某一小时没有数据时返回 NULL绘图前把空值替换为前一个小时的值避免画布出现缺口也可以把这个聚合查询做成Scheduled定时任务每 5 分钟把结果缓存到 Redis小程序读缓存接口响应会更快也更接近工业监控系统的常见做法。本文还有配套的精品资源点击获取