一、前言
1.1 项目开发背景
随着城市化进程的不断推进和人们生活品质的持续提升,户外休闲活动日益成为现代都市人重要的生活方式。公园纳凉、户外露营、小区花园散步等场景已成为人们日常生活中不可或缺的组成部分。然而,这些惬意的户外时光常常被蚊虫侵扰所破坏,传统的灭蚊方式如蚊香、驱蚊液等不仅效果有限,且存在化学残留和安全隐患,难以满足人们对健康环保生活的追求。电蚊拍作为一种物理灭蚊工具,凭借其无污染、高效直接的特性,早已进入千家万户,但传统电蚊拍功能单一、缺乏数据反馈和社交互动,已难以激发当代年轻用户的持续使用兴趣。如何在保留挥拍灭蚊物理快感的基础上,融入物联网技术和游戏化社交元素,打造一款兼具实用性和娱乐性的智能户外装备,成为本项目产品创新的核心思考原点。
物联网技术的迅猛发展为传统硬件产品的智能化升级提供了坚实的技术支撑。4G通信模块的成熟应用使得设备能够随时随地上云,打破了对Wi-Fi网络的依赖,真正实现了户外场景下的实时数据交互。合宙Air780E作为高性能低功耗的4G通信模组,配合MQTT轻量级物联网协议,能够稳定连接华为云物联网平台,为设备与云端的数据传输构建可靠通道。与此同时,基于基站定位的LBS技术使得设备即使在无GPS信号的区域也能获取大致地理位置,进而调用心知天气等第三方API获取实时气象信息,为用户提供天气预报等增值服务。这些技术的有机整合,使原本"笨拙"的电蚊拍具备了环境感知和数据通信能力,完成了从孤立工具到智能终端的跨越式蜕变。
在消费升级的大背景下,用户对产品的期待已从单一功能满足转向了多维度的体验追求。游戏化设计理念正被广泛应用于非游戏类产品中,通过积分、等级、成就、排行榜等机制激发用户的参与感和成就感。本项目将游戏化思维深度植入电蚊拍产品设计,引入击杀计数、语音播报配以欢快背景音乐、成就勋章墙等元素,将枯燥的灭蚊行为转化为富有挑战性和趣味性的游戏任务。每击杀十只蚊子的即时语音反馈、首杀和百人斩等里程碑成就、夜间灭蚊的"夜战之王"特殊称号,这些设计不仅增强了产品的可玩性,更在潜移默化中培养了用户的使用习惯和产品粘性。全球排名和区域排名的引入,则满足了年轻用户群体在社交媒体时代自我展示和社交竞技的心理需求。
移动互联网的深度普及为智能硬件与用户之间搭建了无缝的交互桥梁。微信小程序作为一种轻量级应用形态,无需下载安装、即用即走,完美契合了智能硬件配套应用的使用场景。用户通过微信小程序注册账号并绑定电蚊拍后,可以随时随地查看每日灭蚊数量、历史数据趋势和总击杀数,实现对产品使用情况的全面掌握。每日击杀榜单和每月击杀榜单的呈现,使得原本孤立的个人行为汇入到全球或区域范围内的用户社群中,形成了良性的竞争氛围。好友排行榜和分享海报功能的设计,则充分利用了微信的社交关系链,以裂变式传播的方式扩大产品影响力和用户覆盖面,构建起一个以灭蚊为主题的轻社交生态圈。
户外便携式产品的电源管理始终是设计的重点和难点。本项目采用3.7V容量3000mAh锂电池供电,配合IP5306充电管理芯片实现完善的充放电管理功能,确保设备在户外环境下具备持久的续航能力。IP5306芯片集成了充放电管理、电量检测和多种保护功能,有效保障了锂电池的安全使用和寿命延长。与此同时,考虑到户外场景下4G网络信号不稳定的客观现实,本项目设计了本地FLASH存储机制,当网络不畅时数据自动保存至本地,待网络恢复后自动补传,确保数据的完整性和可靠性。0.96寸OLED屏幕实时显示电池电量、今日灭蚊数量、历史灭蚊总量、诱蚊灯状态和当前日期时间等关键信息,使用户对设备状态一目了然,体现了人性化的产品设计理念。
产品的外形设计同样是本项目的重要创新维度。区别于传统电蚊拍的方正造型,本项目将外壳设计为网球拍或羽毛球拍形状,不仅在视觉上更具运动感和时尚感,在握持手感和挥拍动作上也更符合人体工程学原理,增强了户外使用的舒适性和趣味性。手柄上设置的诱蚊灯开关按钮让用户能够根据实际环境灵活控制诱蚊功能,既可在白天关闭以节省电量,也可在傍晚或夜间开启以增强诱蚊效果。语音播报功能配合欢快背景音乐的设计,使得每次灭蚊成功都伴随着愉悦的听觉反馈,将原本令人烦躁的灭蚊过程转变为充满正能量的互动体验,真正实现了"产品功能"与"情感体验"的深度融合。
综上所述,本项目的开发背景根植于户外休闲生活的真实需求,驱动于物联网技术的成熟发展,升华于游戏化社交理念的深度植入,完善于精细化电源管理和人机交互的精心设计。通过将传统电蚊拍与现代物联网技术、移动互联网应用和游戏化设计理念进行跨界融合,本项目致力于为消费者提供一款兼具实用性、趣味性和社交属性的户外便携式智能装备,重新定义灭蚊产品的价值内涵和用户体验边界。
1.2 设计实现的功能
(1)锂电池供电管理:采用3.7V、容量3000mAh的锂电池为系统供电,并利用IP5306芯片实现锂电池的充放电管理,包括充电保护、放电保护及电量监测。
(2)主控与数据处理:以STM32F103C8T6为主控CPU,负责外设驱动、数据采集、逻辑判断、通信控制及本地存储管理等核心任务。
(3)4G联网与云端通信:采用合宙Air780E 4G通信模组,通过MQTT协议连接华为云物联网平台,实现设备认证、数据上传及远程通信功能。
(4)本地OLED信息显示:通过0.96寸OLED屏幕,实时显示电池电量、当日灭蚊数量、历史累计灭蚊数量、诱蚊灯开关状态以及当前日期时间。
(5)天气预报获取与显示:利用4G模组的基站定位(LBS)获取设备当前大致位置,并调用心知天气API接口获取当地天气信息,最终在OLED屏幕上显示天气预报内容。
(6)诱蚊灯手动控制:在电蚊拍手柄上设置物理按钮,用户可通过该按钮手动开启或关闭诱蚊灯,以适应不同使用环境。
(7)灭蚊数量语音播报:每当灭蚊数量累计达到10的整数倍(如10、20、30……)时,系统自动触发语音播报,并配合欢快背景音乐播放,增强互动反馈。
(8)本地数据存储与断网续传:当4G网络信号不稳定或云端连接异常时,灭蚊数据自动存储至本地FLASH存储器;待网络恢复畅通后,系统自动将本地缓存数据上传至华为云平台,确保数据不丢失。
(9)微信小程序用户绑定与数据查看:用户可通过微信小程序注册个人账号,并完成与电蚊拍设备的绑定操作。绑定后可在小程序端查看当日灭蚊数量、历史每日灭蚊记录以及历史累计灭蚊总量。
(10)微信小程序排行榜功能:小程序内提供每日击杀榜单和每月击杀榜单,支持查看当前全球排名和区域排名,同时可浏览其他用户的灭蚊数量数据,形成社交竞技氛围。
(11)微信小程序成就与分享系统:小程序内置成就勋章墙,包括“首杀”(首次击杀)、“百人斩”(累计击杀100只)、“灭蚊大师”(累计击杀1000只)和“夜战之王”(夜间击杀占比超过80%)等成就标识。同时支持生成包含击杀数和排名信息的小程序海报,便于用户分享至朋友圈等社交平台。
1.3 项目硬件模块组成
(1)主控模块:采用STM32F103C8T6作为核心处理器,负责系统逻辑控制、外设驱动、数据处理及通信协调。
(2)供电与充电管理模块:采用3.7V、容量3000mAh的锂电池作为系统主电源;配合IP5306充电管理芯片,实现锂电池的充放电管理、过充过放保护及电量监测。
(3)4G通信模块:采用合宙Air780E 4G模组,用于设备联网、MQTT协议通信、基站定位(LBS)获取地理位置以及调用心知天气API接口获取天气预报数据。
(4)显示模块:采用0.96寸OLED显示屏(通常为I2C接口),用于本地显示电池电量、今日灭蚊数量、历史灭蚊数量、诱蚊灯开关状态、当前日期时间及天气预报等信息。
(5)高压灭蚊电网模块:由升压电路和金属电网构成,将锂电池低压升压为高压电,用于击杀接触电网的蚊虫,并配合主控进行击杀计数。
(6)诱蚊灯模块:包含紫外诱蚊灯珠(如365nm或395nm紫光LED)及其驱动电路,由手柄上的物理按钮控制开关,用于吸引蚊虫靠近电网。
(7)按键输入模块:在电蚊拍手柄处设置物理按键,用于手动控制诱蚊灯的开启与关闭,向主控提供开关信号。
(8)语音播报模块:包含语音芯片和扬声器,由主控触发,在灭蚊数量达到10的整数倍时播放语音提示及欢快背景音乐。
(9)数据存储模块:采用外部FLASH存储器(如W25Q系列SPI Flash),用于在网络不稳定时本地缓存灭蚊数据,待网络恢复后实现断网续传。
(10)电源转换与管理辅助模块:包含DC-DC-LDO稳压电路,将锂电池3.7V电压转换为系统各模块所需的工作电压(如3.3V、5V等),确保各模块稳定供电。
开源代码下载网盘地址: https://ccnr8sukk85n.feishu.cn/wiki/QjY8weDYHibqRYkFP2qcA9aGnvb?from=from_copylink
1.4 系统框架图
1.5 运行流程图
1.6 设计思路
本项目的设计始于对传统电蚊拍产品功能单一、缺乏用户交互与数据反馈这一痛点的深入思考。在户外休闲日益普及的当下,蚊虫侵扰始终是人们享受公园纳凉、户外露营和小区散步等场景的主要障碍,而现有的灭蚊工具要么依赖化学药剂存在健康隐患,要么功能简单无法提供持续的使用乐趣。基于此,本设计确立了将传统电蚊拍与现代物联网技术、移动互联网应用及游戏化社交机制进行跨界融合的核心方向,旨在打造一款兼具实用灭蚊功能与娱乐社交属性的户外便携式智能装备,让原本枯燥的灭蚊行为转化为具有竞技性和成就感的互动体验。
在系统架构层面,本设计采用分层模块化的设计思路,将整个系统划分为感知控制层、网络通信层、本地应用层和云端服务层四个层次。感知控制层以STM32F103C8T6主控芯片为核心,负责驱动诱蚊灯、高压电网、灭蚊检测传感器等执行器与传感器,并管理按键输入、OLED显示及语音播报等本地交互功能。网络通信层依托合宙Air780E 4G模组,通过MQTT协议实现设备与华为云物联网平台的双向数据交互,同时利用模组的基站定位能力获取设备地理位置,为天气预报功能提供位置基础。本地应用层通过0.96寸OLED显示屏为用户提供直观的设备状态信息,并通过手柄按键和语音反馈实现便捷的本地操控体验。云端服务层则承载用户管理、设备绑定、数据存储、排行榜计算和成就系统等核心业务逻辑,并通过微信小程序向终端用户提供服务。
在电源系统设计方面,本设计充分考虑了户外便携场景对续航能力和安全性的严苛要求。采用3.7V容量3000mAh的锂电池作为主供电来源,并通过IP5306充电管理芯片实现完整的充放电管理功能。IP5306芯片集成了线性充电管理、电量检测、过充保护、过放保护和短路保护等多重安全机制,可确保锂电池在户外复杂环境下安全可靠运行。同时,系统内部通过高效的DC-DC或LDO稳压电路,将锂电池电压转换为各模块所需的不同工作电压,包括为STM32主控和OLED显示屏提供3.3V、为Air780E 4G模组提供4V以及为高压升压电路提供直接的电池电压,以保证各模块均能在最优电压条件下稳定工作。3000mAh的容量设计则充分权衡了设备体积与续航时间的矛盾,确保设备在典型使用场景下能够满足用户单次户外活动乃至多日短途旅行的用电需求。
升压电路作为电蚊拍的核心功能部件,其设计直接关系到灭蚊效果和用户体验。本设计采用单管自激振荡配合三倍压整流的技术方案,利用EE13高频变压器将3.7V低压提升至数百伏交流高压,再经由三级倍压整流电路将电压进一步提升至约2500V至3000V的直流高压,足以击穿空气间隙并瞬间击杀接触电网的蚊虫。在变压器设计上,初级绕组以8匝加8匝中心抽头的方式绕制,配合4匝反馈绕组构成自激振荡回路,次级绕组则以280匝多层密绕的方式实现高升压比。倍压整流电路中选用的FR107快恢复二极管和2.2nF/3kV高压陶瓷电容,均经过充分的耐压裕量计算和安全冗余设计。在电网结构上,采用三层金属网的经典设计,外层和内层接入高压正极,中间层接地,当蚊虫同时接触外层和内层电网时形成放电回路,实现高效击杀。整个高压部分与低压控制区域通过光耦隔离器件实现电气隔离,确保MCU及用户操作部分的安全。
在灭蚊检测与计数功能的设计上,本方案通过在高压放电回路中串联小阻值采样电阻,将蚊虫接触电网瞬间产生的放电电流脉冲转换为电压脉冲信号,经光耦PC817隔离后送入MCU的外部中断引脚。这种非接触式的检测方式既保证了计数的准确性,又实现了高压与低压部分的完全隔离,有效保护了主控芯片和用户的安全。每次有效的放电脉冲触发MCU中断服务程序后,系统自动完成当日击杀数和历史累计击杀数的更新,并将击杀记录附带时间戳保存至本地FLASH存储器中。语音播报功能的设计则在用户累计击杀数量达到10的整数倍时自动触发,通过语音模块播放击杀数量提示并配合欢快的背景音乐,为用户提供即时正向反馈,增强灭蚊过程中的游戏化体验和成就感。
数据存储与网络通信机制的设计充分考虑了户外环境下4G网络信号不稳定的实际情况。本设计采用"本地缓存+云端同步"的双重存储策略,每当产生新的击杀记录时,数据首先被写入板载的W25Q系列SPI FLASH存储器中,确保数据即使在没有网络的情况下也不会丢失。同时,系统通过Air780E 4G模组持续检测网络状态和MQTT云端连接情况,一旦检测到网络畅通且华为云平台连接正常,便自动将本地缓存的未上传记录打包发送至云端服务器,实现数据的断网续传功能。这种设计既保证了数据完整性,又最大限度地降低了因网络波动导致的数据丢失风险。天气预报功能的实现则充分利用了4G模组的基站定位能力,通过LBS获取设备当前所在的大致经纬度信息后,调用心知天气的公开API接口获取该位置的实时天气状况和未来预报,并将天气信息与温度数据显示在本地OLED屏幕上,为用户户外活动提供实用的环境参考信息。
本地人机交互界面的设计以简洁直观为原则,0.96寸OLED显示屏虽尺寸有限,但通过合理的分区布局和信息层级设计,能够清晰展示电池电量百分比、当日灭蚊数量、历史累计灭蚊数量、诱蚊灯开关状态、当前日期时间以及天气预报等六项核心信息。手柄上设置的诱蚊灯物理开关按键,让用户无需进入任何菜单即可在最自然的握持姿态下完成灯光控制,符合户外快速操作的使用习惯。语音播报模块则在特定事件触发时主动发声,不需要用户额外操作,实现了"被动接收信息"的友好交互方式。
在物联网云端架构方面,本设计选用华为云物联网平台作为数据汇聚中心,充分利用其高可用性、海量设备连接能力和成熟的MQTT协议支持。Air780E模组作为设备端MQTT客户端,与平台建立持久连接,定期上报设备状态和灭蚊事件数据。云端平台负责设备鉴权、数据解析、持久化存储和API开放,为微信小程序提供数据服务接口。微信小程序作为面向终端用户的移动端应用,承载了设备绑定、数据可视化、排行榜展示和成就系统等核心社交功能。用户通过小程序注册个人账号后,可扫描设备二维码或手动输入设备ID完成绑定操作,绑定后所有灭蚊数据自动同步至用户账户下。小程序端的数据看板以图表形式直观展示用户的灭蚊趋势和统计数据,排行榜功能则基于全球用户和同区域用户的击杀数据,分别计算每日和每月排名,形成良性的竞技氛围。成就系统预设了首杀、百人斩、灭蚊大师和夜战之王四个里程碑式勋章,当用户满足相应条件时自动解锁并展示在勋章墙中,激励用户持续使用并挑战更高目标。分享卡片功能的设计则允许用户一键生成包含个人击杀数和当前排名的定制化小程序海报,方便分享至微信朋友圈或社交群组,进一步扩大产品的社交传播力。
在产品交互逻辑的时序设计上,系统上电后首先完成各外设的初始化和自检,随后Air780E模组开机并尝试注册网络,建立MQTT连接。OLED屏幕在启动过程中显示设备标识和初始化状态,完成后进入主信息界面循环刷新。灭蚊检测中断具有最高优先级,确保每次击杀事件都能被及时捕获和处理,中断服务程序内仅做标志位设置和简单计数更新,耗时操作如语音播报和云端上传则通过主循环中的任务调度机制异步执行,避免影响中断响应的实时性。按键扫描通过定时器中断周期性进行,配合软件消抖算法,有效避免了机械按键的误触发问题。系统整体的任务调度采用非阻塞式的状态机设计,保证各功能模块能够协同工作而互不干扰。
安全可靠性设计贯穿于本项目的所有环节。在电气安全方面,高压升压电路与低压控制电路之间通过光耦隔离实现完全的电气隔离,高压电网输出端并联了放电电阻确保断电后快速泄放余电,电池管理芯片集成了多重保护机制防止过充过放和短路故障。在数据安全方面,用户账号与设备之间的绑定关系通过云端数据库进行严格校验,设备上报的数据带有时间戳和设备ID双重标识,防止数据混淆和篡改。在户外使用可靠性方面,所有电子元器件均选用工业级规格,工作温度范围覆盖-20℃至85℃,PCB板采用三防漆喷涂处理以增强防潮防尘能力,外壳结构设计充分考虑跌落防护和握持舒适性,以网球拍和羽毛球拍为造型灵感的手柄设计既符合人体工程学原理,又增添了产品的运动时尚感。
综上所述,本项目的设计思路始终围绕"户外便携、游戏化灭蚊、物联网社交"三大核心定位展开,在充分满足传统电蚊拍基础灭蚊功能的前提下,通过STM32嵌入式系统设计、4G云通信技术应用、本地数据管理与云端同步机制、微信小程序社交功能开发等多个技术维度的有机融合,将一款普通的家用灭蚊工具升级为具有实时数据反馈、全球排行榜竞技和成就激励体系的智能硬件产品,实现了从功能性产品向体验式产品的价值跃升。
二、部署华为云物联网平台
华为云官网: https://www.huaweicloud.com/
打开官网,搜索物联网,就能快速找到 设备接入IoTDA。
2.1 物联网平台介绍
华为云物联网平台(IoT 设备接入云服务)提供海量设备的接入和管理能力,将物理设备联接到云,支撑设备数据采集上云和云端下发命令给设备进行远程控制,配合华为云其他产品,帮助我们快速构筑物联网解决方案。
使用物联网平台构建一个完整的物联网解决方案主要包括3部分:物联网平台、业务应用和设备。
物联网平台作为连接业务应用和设备的中间层,屏蔽了各种复杂的设备接口,实现设备的快速接入;同时提供强大的开放能力,支撑行业用户构建各种物联网解决方案。
设备可以通过固网、2G/3G/4G/5G、NB-IoT、Wifi等多种网络接入物联网平台,并使用LWM2M/CoAP、MQTT、HTTPS协议将业务数据上报到平台,平台也可以将控制命令下发给设备。
业务应用通过调用物联网平台提供的API,实现设备数据采集、命令下发、设备管理等业务场景。
2.2 开通物联网服务
地址: https://www.huaweicloud.com/product/iothub.html
开通免费单元。
点击立即创建。
正在创建标准版实例,需要等待片刻。
创建完成之后,点击详情。 可以看到标准版实例的设备接入端口和地址。
下面框起来的就是端口号和域名
点击实例名称,可以查看当前免费单元的配置情况。
开通之后,点击接入信息,也能查看接入信息。 我们当前设备准备采用MQTT协议接入华为云平台,这里可以看到MQTT协议的地址和端口号等信息。
总结:
端口号: MQTT (1883)| MQTTS (8883)
接入地址: dab1a1f2c6.st1.iotda-device.cn-north-4.myhuaweicloud.com
根据域名地址得到IP地址信息:
打开Windows电脑的命令行控制台终端,使用ping 命令。ping一下即可。
Microsoft Windows [版本 10.0.19045.5011]
(c) Microsoft Corporation。保留所有权利。
C:UsersLenovo>ping dab1a1f2c6.st1.iotda-device.cn-north-4.myhuaweicloud.com
正在 Ping dab1a1f2c6.st1.iotda-device.cn-north-4.myhuaweicloud.com [117.78.5.125] 具有 32 字节的数据:
来自 117.78.5.125 的回复: 字节=32 时间=37ms TTL=44
来自 117.78.5.125 的回复: 字节=32 时间=37ms TTL=44
来自 117.78.5.125 的回复: 字节=32 时间=37ms TTL=44
来自 117.78.5.125 的回复: 字节=32 时间=37ms TTL=44
117.78.5.125 的 Ping 统计信息:
数据包: 已发送 = 4,已接收 = 4,丢失 = 0 (0% 丢失),
往返行程的估计时间(以毫秒为单位):
最短 = 37ms,最长 = 37ms,平均 = 37ms
C:UsersLenovo>
MQTT协议接入端口号有两个,1883是非加密端口,8883是证书加密端口,单片机无法加载证书,所以使用1883端口合适。
2.3 创建产品
链接:https://console.huaweicloud.com/iotdm/?region=cn-north-4#/dm-dev/all-product?instanceId=03c5c68c-e588-458c-90c3-9e4c640be7af
(1)创建产品
(2)填写产品信息
根据自己产品名字填写,下面的设备类型选择自定义类型。
(3)产品创建成功
创建完成之后点击查看详情。
(4)添加自定义模型
产品创建完成之后,点击进入产品详情页面,翻到最下面可以看到模型定义。
模型简单来说: 就是存放设备上传到云平台的数据。
你可以根据自己的产品进行创建。
比如:
烟雾可以叫 MQ2
温度可以叫 Temperature
湿度可以叫 humidity
火焰可以叫 flame
其他的传感器自己用单词简写命名即可。 这就是你的单片机设备端上传到服务器的数据名字。
先点击自定义模型。
再创建一个服务ID。
接着点击新增属性。
2.4 添加设备
产品是属于上层的抽象模型,接下来在产品模型下添加实际的设备。添加的设备最终需要与真实的设备关联在一起,完成数据交互。
(1)注册设备
(2)根据自己的设备填写
(3)保存设备信息
创建完毕之后,点击保存并关闭,得到创建的设备密匙信息。该信息在后续生成MQTT三元组的时候需要使用。
(4)设备创建完成
(5)设备详情
2.5 MQTT协议主题订阅与发布
(1)MQTT协议介绍
当前的设备是采用MQTT协议与华为云平台进行通信。
MQTT是一个物联网传输协议,它被设计用于轻量级的发布/订阅式消息传输,旨在为低带宽和不稳定的网络环境中的物联网设备提供可靠的网络服务。MQTT是专门针对物联网开发的轻量级传输协议。MQTT协议针对低带宽网络,低计算能力的设备,做了特殊的优化,使得其能适应各种物联网应用场景。目前MQTT拥有各种平台和设备上的客户端,已经形成了初步的生态系统。
MQTT是一种消息队列协议,使用发布/订阅消息模式,提供一对多的消息发布,解除应用程序耦合,相对于其他协议,开发更简单;MQTT协议是工作在TCP/IP协议上;由TCP/IP协议提供稳定的网络连接;所以,只要具备TCP协议栈的网络设备都可以使用MQTT协议。 本次设备采用的ESP8266就具备TCP协议栈,能够建立TCP连接,所以,配合STM32代码里封装的MQTT协议,就可以与华为云平台完成通信。
华为云的MQTT协议接入帮助文档在这里: https://support.huaweicloud.com/devg-iothub/iot_02_2200.html
业务流程:
(2)华为云平台MQTT协议使用限制
| 描述 | 限制 |
|---|---|
| 支持的MQTT协议版本 | 3.1.1 |
| 与标准MQTT协议的区别 | 支持Qos 0和Qos 1支持Topic自定义不支持QoS2不支持will、retain msg |
| MQTTS支持的安全等级 | 采用TCP通道基础 + TLS协议(最高TLSv1.3版本) |
| 单帐号每秒最大MQTT连接请求数 | 无限制 |
| 单个设备每分钟支持的最大MQTT连接数 | 1 |
| 单个MQTT连接每秒的吞吐量,即带宽,包含直连设备和网关 | 3KB/s |
| MQTT单个发布消息最大长度,超过此大小的发布请求将被直接拒绝 | 1MB |
| MQTT连接心跳时间建议值 | 心跳时间限定为30至1200秒,推荐设置为120秒 |
| 产品是否支持自定义Topic | 支持 |
| 消息发布与订阅 | 设备只能对自己的Topic进行消息发布与订阅 |
| 每个订阅请求的最大订阅数 | 无限制 |
(3)主题订阅格式
帮助文档地址:https://support.huaweicloud.com/devg-iothub/iot_02_2200.html
对于设备而言,一般会订阅平台下发消息给设备 这个主题。
设备想接收平台下发的消息,就需要订阅平台下发消息给设备 的主题,订阅后,平台下发消息给设备,设备就会收到消息。
如果设备想要知道平台下发的消息,需要订阅上面图片里标注的主题。
以当前设备为例,最终订阅主题的格式如下:
$oc/devices/{device_id}/sys/messages/down
最终的格式:
$oc/devices/663cb18871d845632a0912e7_dev1/sys/messages/down
(4)主题发布格式
对于设备来说,主题发布表示向云平台上传数据,将最新的传感器数据,设备状态上传到云平台。
这个操作称为:属性上报。
帮助文档地址:https://support.huaweicloud.com/usermanual-iothub/iot_06_v5_3010.html
根据帮助文档的介绍, 当前设备发布主题,上报属性的格式总结如下:
发布的主题格式:
$oc/devices/{device_id}/sys/properties/report
最终的格式:
$oc/devices/663cb18871d845632a0912e7_dev1/sys/properties/report
发布主题时,需要上传数据,这个数据格式是JSON格式。
上传的JSON数据格式如下:
{
"services": [
{
"service_id": <填服务ID>,
"properties": {
"<填属性名称1>": <填属性值>,
"<填属性名称2>": <填属性值>,
..........
}
}
]
}
根据JSON格式,一次可以上传多个属性字段。 这个JSON格式里的,服务ID,属性字段名称,属性值类型,在前面创建产品的时候就已经介绍了,不记得可以翻到前面去查看。
根据这个格式,组合一次上传的属性数据:
{"services": [{"service_id": "stm32","properties":{"你的字段名字1":30,"你的字段名字2":10,"你的字段名字3":1,"你的字段名字4":0}}]}
2.6 MQTT三元组
MQTT协议登录需要填用户ID,设备ID,设备密码等信息,就像我们平时登录QQ,微信一样要输入账号密码才能登录。MQTT协议登录的这3个参数,一般称为MQTT三元组。
接下来介绍,华为云平台的MQTT三元组参数如何得到。
(1)MQTT服务器地址
要登录MQTT服务器,首先记得先知道服务器的地址是多少,端口是多少。
帮助文档地址:https://console.huaweicloud.com/iotdm/?region=cn-north-4#/dm-portal/home
MQTT协议的端口支持1883和8883,它们的区别是:8883 是加密端口更加安全。但是单片机上使用比较困难,所以当前的设备是采用1883端口进连接的。
根据上面的域名和端口号,得到下面的IP地址和端口号信息: 如果设备支持填写域名可以直接填域名,不支持就直接填写IP地址。 (IP地址就是域名解析得到的)
华为云的MQTT服务器地址:117.78.5.125
华为云的MQTT端口号:1883
如何得到IP地址?如何域名转IP? 打开Windows的命令行输入以下命令。
ping ad635970a1.st1.iotda-device.cn-north-4.myhuaweicloud.com
(2)生成MQTT三元组
华为云提供了一个在线工具,用来生成MQTT鉴权三元组: https://iot-tool.obs-website.cn-north-4.myhuaweicloud.com/
打开这个工具,填入设备的信息(也就是刚才创建完设备之后保存的信息),点击生成,就可以得到MQTT的登录信息了。
下面是打开的页面:
填入设备的信息: (上面两行就是设备创建完成之后保存得到的)
直接得到三元组信息。
得到三元组之后,设备端通过MQTT协议登录鉴权的时候,填入参数即可。
ClientId 663cb18871d845632a0912e7_dev1_0_0_2024050911
Username 663cb18871d845632a0912e7_dev1
Password 71b82deae83e80f04c4269b5bbce3b2fc7c13f610948fe210ce18650909ac237
2.7 模拟设备登录测试
经过上面的步骤介绍,已经创建了产品,设备,数据模型,得到MQTT登录信息。 接下来就用MQTT客户端软件模拟真实的设备来登录平台。测试与服务器通信是否正常。
MQTT软件下载地址【免费】: https://download.csdn.net/download/xiaolong1126626497/89928772
(1)填入登录信息
打开MQTT客户端软件,对号填入相关信息(就是上面的文本介绍)。然后,点击登录,订阅主题,发布主题。
(2)打开网页查看
完成上面的操作之后,打开华为云网页后台,可以看到设备已经在线了。
点击详情页面,可以看到上传的数据:
到此,云平台的部署已经完成,设备已经可以正常上传数据了。
(3)MQTT登录测试参数总结
MQTT服务器: 117.78.5.125
MQTT端口号: 183
//物联网服务器的设备信息
#define MQTT_ClientID "663cb18871d845632a0912e7_dev1_0_0_2024050911"
#define MQTT_UserName "663cb18871d845632a0912e7_dev1"
#define MQTT_PassWord "71b82deae83e80f04c4269b5bbce3b2fc7c13f610948fe210ce18650909ac237"
//订阅与发布的主题
#define SET_TOPIC "$oc/devices/663cb18871d845632a0912e7_dev1/sys/messages/down" //订阅
#define POST_TOPIC "$oc/devices/663cb18871d845632a0912e7_dev1/sys/properties/report" //发布
发布的数据:
{"services": [{"service_id": "stm32","properties":{"你的字段名字1":30,"你的字段名字2":10,"你的字段名字3":1,"你的字段名字4":0}}]}
2.8 创建IAM账户
创建一个IAM账户,因为接下来开发上位机,需要使用云平台的API接口,这些接口都需要token进行鉴权。简单来说,就是身份的认证。 调用接口获取Token时,就需要填写IAM账号信息。所以,接下来演示一下过程。
地址: https://console.huaweicloud.com/iam/?region=cn-north-4#/iam/users
**【1】获取项目凭证 ** 点击左上角用户名,选择下拉菜单里的我的凭证
项目凭证:
28add376c01e4a61ac8b621c714bf459
【2】创建IAM用户
鼠标放在左上角头像上,在下拉菜单里选择统一身份认证。
点击左上角创建用户。
创建成功:
【3】创建完成
用户信息如下:
主用户名 l19504562721
IAM用户 ds_abc
密码 DS12345678
2.9 获取影子数据
帮助文档:https://support.huaweicloud.com/api-iothub/iot_06_v5_0079.html
设备影子介绍:
设备影子是一个用于存储和检索设备当前状态信息的JSON文档。
每个设备有且只有一个设备影子,由设备ID唯一标识
设备影子仅保存最近一次设备的上报数据和预期数据
无论该设备是否在线,都可以通过该影子获取和设置设备的属性
简单来说:设备影子就是保存,设备最新上传的一次数据。
我们设计的软件里,如果想要获取设备的最新状态信息,就采用设备影子接口。
如果对接口不熟悉,可以先进行在线调试:https://apiexplorer.developer.huaweicloud.com/apiexplorer/doc?product=IoTDA&api=ShowDeviceShadow
在线调试接口,可以请求影子接口,了解请求,与返回的数据格式。
调试完成看右下角的响应体,就是返回的影子数据。
设备影子接口返回的数据如下:
{
"device_id": "663cb18871d845632a0912e7_dev1",
"shadow": [
{
"service_id": "stm32",
"desired": {
"properties": null,
"event_time": null
},
"reported": {
"properties": {
"DHT11_T": 18,
"DHT11_H": 90,
"BH1750": 38,
"MQ135": 70
},
"event_time": "20240509T113448Z"
},
"version": 3
}
]
}
调试成功之后,可以得到访问影子数据的真实链接,接下来的代码开发中,就采用Qt写代码访问此链接,获取影子数据,完成上位机开发。
链接如下:
https://ad635970a1.st1.iotda-app.cn-north-4.myhuaweicloud.com:443/v5/iot/28add376c01e4a61ac8b621c714bf459/devices/663cb18871d845632a0912e7_dev1/shadow
三、微信小程序开发环境搭建
3.1 下载开发工具
链接地址:https://developers.weixin.qq.com/miniprogram/dev/devtools/log.html#stable-2.01.2510290
3.2 安装开发工具
双击安装。
点击下一步。
安装完毕了找到开发工具的位置。
如果桌面没有自动创建快捷方式。可以自己创建一个。
3.3 打开使用开发工具
进去之后,可以微信扫码登录。
关闭默认打开的工程。就会看到下面的页面。
选择 + 号。
3.4 新建工程
APPID可以注册一个,也可先使用测试号。 然后,选择小程序,模版选择基础,选择-JS-基础模版。
选择创建。
工程打开之后就可以点击预览测试。
微信扫码就可以看默认的效果了。
3.5 代码设计
mosquito-killer-miniapp/
├── app.js // 小程序全局逻辑
├── app.json // 小程序全局配置
├── app.wxss // 小程序全局样式
├── project.config.json // 项目配置
├── sitemap.json // 站点地图
├── pages/
│ ├── index/ // 首页 - 设备绑定与概览
│ │ ├── index.js
│ │ ├── index.json
│ │ ├── index.wxml
│ │ └── index.wxss
│ ├── dashboard/ // 数据看板 - 灭蚊统计
│ │ ├── dashboard.js
│ │ ├── dashboard.json
│ │ ├── dashboard.wxml
│ │ └── dashboard.wxss
│ ├── rank/ // 排行榜
│ │ ├── rank.js
│ │ ├── rank.json
│ │ ├── rank.wxml
│ │ └── rank.wxss
│ ├── achievements/ // 成就勋章墙
│ │ ├── achievements.js
│ │ ├── achievements.json
│ │ ├── achievements.wxml
│ │ └── achievements.wxss
│ ├── profile/ // 个人中心
│ │ ├── profile.js
│ │ ├── profile.json
│ │ ├── profile.wxml
│ │ └── profile.wxss
│ └── share/ // 分享海报生成
│ ├── share.js
│ ├── share.json
│ ├── share.wxml
│ └── share.wxss
├── components/ // 自定义组件
│ ├── kill-card/ // 击杀数卡片组件
│ │ ├── kill-card.js
│ │ ├── kill-card.json
│ │ ├── kill-card.wxml
│ │ └── kill-card.wxss
│ └── rank-item/ // 排行榜条目组件
│ ├── rank-item.js
│ ├── rank-item.json
│ ├── rank-item.wxml
│ └── rank-item.wxss
├── utils/ // 工具函数
│ ├── api.js // API接口封装
│ ├── mqtt.js // MQTT连接管理
│ ├── util.js // 通用工具函数
│ └── constants.js // 常量定义
├── images/ // 图片资源
│ ├── icons/
│ └── badges/
└── cloudfunctions/ // 云函数
├── login/ // 用户登录
├── bindDevice/ // 设备绑定
├── getKillData/ // 获取灭蚊数据
├── getRank/ // 获取排行榜
├── getAchievements/ // 获取成就
└── generatePoster/ // 生成分享海报
1. app.json - 全局配置
{
"pages": [
"pages/index/index",
"pages/dashboard/dashboard",
"pages/rank/rank",
"pages/achievements/achievements",
"pages/profile/profile",
"pages/share/share"
],
"window": {
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTitleText": "智能电蚊拍",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f5f5",
"backgroundTextStyle": "light"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#4CAF50",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "images/icons/home.png",
"selectedIconPath": "images/icons/home_active.png"
},
{
"pagePath": "pages/dashboard/dashboard",
"text": "数据",
"iconPath": "images/icons/data.png",
"selectedIconPath": "images/icons/data_active.png"
},
{
"pagePath": "pages/rank/rank",
"text": "排行",
"iconPath": "images/icons/rank.png",
"selectedIconPath": "images/icons/rank_active.png"
},
{
"pagePath": "pages/achievements/achievements",
"text": "成就",
"iconPath": "images/icons/badge.png",
"selectedIconPath": "images/icons/badge_active.png"
},
{
"pagePath": "pages/profile/profile",
"text": "我的",
"iconPath": "images/icons/profile.png",
"selectedIconPath": "images/icons/profile_active.png"
}
]
},
"requiredBackgroundModes": [],
"sitemapLocation": "sitemap.json"
}
2. app.js - 全局逻辑
// app.js
App({
globalData: {
userInfo: null,
deviceId: null,
isConnected: false,
mqttClient: null,
apiBase: 'https://your-api-domain.com', // 替换为实际API地址
cloudEnv: 'your-cloud-env-id' // 替换为实际云环境ID
},
onLaunch() {
// 初始化云开发
wx.cloud.init({
env: this.globalData.cloudEnv,
traceUser: true
});
// 检查登录状态
this.checkLoginStatus();
// 获取系统信息
this.getSystemInfo();
},
checkLoginStatus() {
const userInfo = wx.getStorageSync('userInfo');
if (userInfo) {
this.globalData.userInfo = userInfo;
}
},
getSystemInfo() {
wx.getSystemInfo({
success: (res) => {
this.globalData.systemInfo = res;
}
});
},
// 全局登录方法
login() {
return new Promise((resolve, reject) => {
wx.login({
success: (res) => {
if (res.code) {
// 调用云函数获取openid
wx.cloud.callFunction({
name: 'login',
data: { code: res.code },
success: (result) => {
const userInfo = result.result.userInfo;
this.globalData.userInfo = userInfo;
wx.setStorageSync('userInfo', userInfo);
resolve(userInfo);
},
fail: reject
});
} else {
reject(new Error('登录失败'));
}
},
fail: reject
});
});
},
// 设备绑定
bindDevice(deviceId) {
return new Promise((resolve, reject) => {
wx.cloud.callFunction({
name: 'bindDevice',
data: {
deviceId: deviceId,
userId: this.globalData.userInfo._id
},
success: (res) => {
this.globalData.deviceId = deviceId;
wx.setStorageSync('deviceId', deviceId);
resolve(res);
},
fail: reject
});
});
}
});
3. utils/api.js - API接口封装
// utils/api.js
const app = getApp();
// 基础请求封装
const request = (url, method = 'GET', data = {}) => {
return new Promise((resolve, reject) => {
wx.request({
url: `${app.globalData.apiBase}${url}`,
method: method,
data: data,
header: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${wx.getStorageSync('token') || ''}`
},
success: (res) => {
if (res.statusCode === 200 && res.data.code === 0) {
resolve(res.data.data);
} else {
reject(res.data.message || '请求失败');
}
},
fail: reject
});
});
};
// ==================== 设备相关 ====================
// 绑定设备
export const bindDevice = (deviceId) => {
return request('/device/bind', 'POST', { deviceId });
};
// 解绑设备
export const unbindDevice = () => {
return request('/device/unbind', 'POST');
};
// 获取设备状态
export const getDeviceStatus = () => {
return request('/device/status');
};
// 远程控制设备(诱蚊灯开关)
export const controlDevice = (action) => {
return request('/device/control', 'POST', { action });
};
// ==================== 灭蚊数据相关 ====================
// 获取今日灭蚊数据
export const getTodayKill = () => {
return request('/kill/today');
};
// 获取历史灭蚊数据(按日)
export const getHistoryKill = (params) => {
return request('/kill/history', 'GET', params);
};
// 获取总灭蚊数
export const getTotalKill = () => {
return request('/kill/total');
};
// 获取每日灭蚊趋势
export const getDailyTrend = (days = 30) => {
return request('/kill/trend', 'GET', { days });
};
// ==================== 排行榜相关 ====================
// 获取全球排行榜
export const getGlobalRank = (type = 'daily') => {
return request('/rank/global', 'GET', { type });
};
// 获取区域排行榜
export const getRegionRank = (type = 'daily') => {
return request('/rank/region', 'GET', { type });
};
// 获取好友排行榜
export const getFriendRank = () => {
return request('/rank/friends');
};
// 获取用户排名
export const getUserRank = () => {
return request('/rank/user');
};
// ==================== 成就相关 ====================
// 获取用户成就列表
export const getAchievements = () => {
return request('/achievements/list');
};
// 获取成就勋章详情
export const getBadgeDetail = (badgeId) => {
return request('/achievements/detail', 'GET', { badgeId });
};
// ==================== 分享相关 ====================
// 生成分享海报
export const generatePoster = (data) => {
return request('/share/poster', 'POST', data);
};
// ==================== 用户相关 ====================
// 获取用户信息
export const getUserInfo = () => {
return request('/user/info');
};
// 更新用户信息
export const updateUserInfo = (data) => {
return request('/user/update', 'POST', data);
};
export default {
bindDevice,
unbindDevice,
getDeviceStatus,
controlDevice,
getTodayKill,
getHistoryKill,
getTotalKill,
getDailyTrend,
getGlobalRank,
getRegionRank,
getFriendRank,
getUserRank,
getAchievements,
getBadgeDetail,
generatePoster,
getUserInfo,
updateUserInfo
};
4. utils/mqtt.js - MQTT连接管理
// utils/mqtt.js
// 使用 Paho MQTT 客户端库 (需在项目中引入)
import * as Paho from 'mqtt';
let client = null;
let reconnectTimer = null;
let messageHandlers = [];
const MQTT_CONFIG = {
host: 'broker.huaweicloud.com',
port: 1883,
clientId: `wx_${Date.now()}`,
username: 'your-device-id',
password: 'your-password'
};
// 连接MQTT
export const connect = (onConnect, onMessage, onClose) => {
if (client && client.isConnected()) {
onConnect && onConnect();
return;
}
client = new Paho.Client(MQTT_CONFIG.host, MQTT_CONFIG.port, MQTT_CONFIG.clientId);
client.onConnectionLost = (response) => {
console.log('MQTT连接断开', response);
// 自动重连
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
connect(onConnect, onMessage, onClose);
}, 5000);
onClose && onClose(response);
};
client.onMessageArrived = (message) => {
console.log('收到MQTT消息:', message.destinationName, message.payloadString);
messageHandlers.forEach(handler => {
handler(message.destinationName, JSON.parse(message.payloadString || '{}'));
});
onMessage && onMessage(message.destinationName, message.payloadString);
};
client.connect({
userName: MQTT_CONFIG.username,
password: MQTT_CONFIG.password,
useSSL: false,
onSuccess: () => {
console.log('MQTT连接成功');
// 订阅设备数据主题
client.subscribe(`device/${MQTT_CONFIG.username}/#`);
onConnect && onConnect();
},
onFailure: (err) => {
console.error('MQTT连接失败', err);
// 重试
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
connect(onConnect, onMessage, onClose);
}, 5000);
}
});
};
// 断开连接
export const disconnect = () => {
if (client && client.isConnected()) {
client.disconnect();
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
// 发布消息
export const publish = (topic, message) => {
if (client && client.isConnected()) {
const msg = new Paho.Message(JSON.stringify(message));
msg.destinationName = topic;
client.send(msg);
return true;
}
return false;
};
// 订阅主题
export const subscribe = (topic) => {
if (client && client.isConnected()) {
client.subscribe(topic);
}
};
// 注册消息处理器
export const onMessage = (handler) => {
messageHandlers.push(handler);
};
// 移除消息处理器
export const offMessage = (handler) => {
const index = messageHandlers.indexOf(handler);
if (index > -1) {
messageHandlers.splice(index, 1);
}
};
// 获取连接状态
export const isConnected = () => {
return client && client.isConnected();
};
export default {
connect,
disconnect,
publish,
subscribe,
onMessage,
offMessage,
isConnected
};
5. pages/index/index - 首页(设备绑定与概览)
index.wxml
<!-- pages/index/index.wxml -->
<view class="container">
<!-- 头部 -->
<view class="header">
<view class="header-title">🦟 智能电蚊拍</view>
<view class="header-sub">挥拍灭蚊,全球竞技</view>
</view>
<!-- 设备绑定区域 -->
<view class="device-section">
<view class="section-title">设备连接</view>
<view class="device-status" wx:if="{{deviceBound}}">
<view class="status-item">
<text class="status-label">设备ID:</text>
<text class="status-value">{{deviceId}}</text>
</view>
<view class="status-item">
<text class="status-label">连接状态:</text>
<text class="status-value {{mqttConnected ? 'online' : 'offline'}}">
{{mqttConnected ? '已连接' : '未连接'}}
</text>
</view>
<view class="status-item">
<text class="status-label">电池电量:</text>
<text class="status-value">{{batteryLevel}}%</text>
<view class="battery-bar">
<view class="battery-fill" style="width:{{batteryLevel}}%"></view>
</view>
</view>
<view class="control-btns">
<button class="btn-control {{ledStatus ? 'active' : ''}}" bindtap="toggleLed">
{{ledStatus ? '关闭诱蚊灯' : '开启诱蚊灯'}}
</button>
<button class="btn-control" bindtap="unbindDevice">解绑设备</button>
</view>
</view>
<!-- 未绑定状态 -->
<view class="device-unbound" wx:else>
<view class="empty-state">
<image src="/images/device_bind.png" class="empty-img"></image>
<text class="empty-text">请扫描设备二维码绑定</text>
<button class="btn-bind" bindtap="scanQRCode">扫描绑定</button>
<text class="bind-hint">或手动输入设备ID</text>
<input class="device-input" placeholder="请输入设备ID" bindinput="onDeviceInput" />
<button class="btn-bind" bindtap="bindDevice">手动绑定</button>
</view>
</view>
</view>
<!-- 今日数据概览 -->
<view class="data-section" wx:if="{{deviceBound}}">
<view class="section-title">今日战绩</view>
<view class="data-grid">
<view class="data-card">
<view class="data-number">{{todayKill}}</view>
<view class="data-label">今日击杀</view>
</view>
<view class="data-card">
<view class="data-number">{{totalKill}}</view>
<view class="data-label">累计击杀</view>
</view>
<view class="data-card">
<view class="data-number">{{dailyRank || '-'}}</view>
<view class="data-label">今日排名</view>
</view>
</view>
</view>
<!-- 快捷入口 -->
<view class="quick-entry" wx:if="{{deviceBound}}">
<view class="entry-item" bindtap="goToDashboard">
<image src="/images/icons/data.png"></image>
<text>数据详情</text>
</view>
<view class="entry-item" bindtap="goToRank">
<image src="/images/icons/rank.png"></image>
<text>排行榜</text>
</view>
<view class="entry-item" bindtap="goToAchievements">
<image src="/images/icons/badge.png"></image>
<text>勋章墙</text>
</view>
</view>
</view>
index.js
// pages/index/index.js
import api from '../../utils/api';
import mqtt from '../../utils/mqtt';
const app = getApp();
Page({
data: {
deviceBound: false,
deviceId: '',
mqttConnected: false,
batteryLevel: 0,
ledStatus: false,
todayKill: 0,
totalKill: 0,
dailyRank: null,
deviceInput: ''
},
onLoad() {
this.checkBindStatus();
this.initMQTT();
},
onShow() {
if (this.data.deviceBound) {
this.refreshData();
}
},
onUnload() {
mqtt.disconnect();
},
// 检查设备绑定状态
checkBindStatus() {
const deviceId = wx.getStorageSync('deviceId');
if (deviceId) {
this.setData({
deviceBound: true,
deviceId: deviceId
});
app.globalData.deviceId = deviceId;
this.getDeviceStatus();
this.refreshData();
}
},
// 初始化MQTT连接
initMQTT() {
if (!this.data.deviceBound) return;
mqtt.connect(
() => {
this.setData({ mqttConnected: true });
this.subscribeDeviceTopic();
},
(topic, payload) => {
this.handleMqttMessage(topic, payload);
},
() => {
this.setData({ mqttConnected: false });
}
);
// 注册消息处理器
mqtt.onMessage(this.handleMqttMessage.bind(this));
},
// 订阅设备主题
subscribeDeviceTopic() {
const deviceId = this.data.deviceId;
mqtt.subscribe(`device/${deviceId}/status`);
mqtt.subscribe(`device/${deviceId}/kill`);
},
// 处理MQTT消息
handleMqttMessage(topic, payload) {
console.log('MQTT消息:', topic, payload);
if (topic.includes('status')) {
this.setData({
batteryLevel: payload.battery || 0,
ledStatus: payload.ledState || false
});
} else if (topic.includes('kill')) {
this.refreshData();
}
},
// 获取设备状态
async getDeviceStatus() {
try {
const res = await api.getDeviceStatus();
this.setData({
batteryLevel: res.battery || 0,
ledStatus: res.ledState || false
});
} catch (err) {
console.error('获取设备状态失败:', err);
}
},
// 刷新数据
async refreshData() {
wx.showLoading({ title: '加载中...' });
try {
const [todayRes, totalRes] = await Promise.all([
api.getTodayKill(),
api.getTotalKill()
]);
this.setData({
todayKill: todayRes.count || 0,
totalKill: totalRes.count || 0
});
// 获取今日排名
try {
const rankRes = await api.getUserRank();
this.setData({ dailyRank: rankRes.rank });
} catch (e) {
console.log('获取排名失败');
}
} catch (err) {
wx.showToast({ title: '加载失败', icon: 'none' });
} finally {
wx.hideLoading();
}
},
// 扫描二维码绑定
scanQRCode() {
wx.scanCode({
onlyFromCamera: true,
success: (res) => {
const deviceId = res.result;
this.doBindDevice(deviceId);
},
fail: () => {
wx.showToast({ title: '扫描取消', icon: 'none' });
}
});
},
// 手动输入绑定
onDeviceInput(e) {
this.setData({ deviceInput: e.detail.value });
},
bindDevice() {
const deviceId = this.data.deviceInput.trim();
if (!deviceId) {
wx.showToast({ title: '请输入设备ID', icon: 'none' });
return;
}
this.doBindDevice(deviceId);
},
// 执行绑定
async doBindDevice(deviceId) {
wx.showLoading({ title: '绑定中...' });
try {
await api.bindDevice(deviceId);
await app.bindDevice(deviceId);
this.setData({
deviceBound: true,
deviceId: deviceId,
deviceInput: ''
});
wx.showToast({ title: '绑定成功', icon: 'success' });
// 初始化MQTT
this.initMQTT();
this.refreshData();
} catch (err) {
wx.showToast({ title: err.message || '绑定失败', icon: 'none' });
} finally {
wx.hideLoading();
}
},
// 解绑设备
unbindDevice() {
wx.showModal({
title: '确认解绑',
content: '解绑后将无法接收设备数据,确定继续?',
success: async (res) => {
if (res.confirm) {
try {
await api.unbindDevice();
wx.removeStorageSync('deviceId');
mqtt.disconnect();
this.setData({
deviceBound: false,
deviceId: '',
mqttConnected: false
});
wx.showToast({ title: '解绑成功', icon: 'success' });
} catch (err) {
wx.showToast({ title: '解绑失败', icon: 'none' });
}
}
}
});
},
// 控制诱蚊灯
async toggleLed() {
const action = this.data.ledStatus ? 'led_off' : 'led_on';
wx.showLoading({ title: '发送指令...' });
try {
await api.controlDevice({ action });
this.setData({ ledStatus: !this.data.ledStatus });
wx.showToast({ title: '指令已发送', icon: 'success' });
} catch (err) {
wx.showToast({ title: '操作失败', icon: 'none' });
} finally {
wx.hideLoading();
}
},
// 页面跳转
goToDashboard() {
wx.switchTab({ url: '/pages/dashboard/dashboard' });
},
goToRank() {
wx.switchTab({ url: '/pages/rank/rank' });
},
goToAchievements() {
wx.switchTab({ url: '/pages/achievements/achievements' });
},
// 分享
onShareAppMessage() {
return {
title: '🦟 我用智能电蚊拍消灭了' + this.data.totalKill + '只蚊子!',
path: '/pages/index/index',
imageUrl: '/images/share_cover.png'
};
}
});
index.wxss
/* pages/index/index.wxss */
.container {
padding: 20rpx 30rpx 100rpx;
background: #f5f7fa;
min-height: 100vh;
}
.header {
text-align: center;
padding: 40rpx 0 30rpx;
}
.header-title {
font-size: 48rpx;
font-weight: bold;
color: #2d3436;
}
.header-sub {
font-size: 28rpx;
color: #888;
margin-top: 10rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #2d3436;
padding: 20rpx 0;
}
.device-section {
background: #fff;
border-radius: 20rpx;
padding: 20rpx 30rpx 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
}
.device-status .status-item {
display: flex;
align-items: center;
padding: 12rpx 0;
font-size: 28rpx;
}
.status-label {
color: #888;
width: 160rpx;
}
.status-value {
color: #2d3436;
font-weight: 500;
}
.status-value.online { color: #00b894; }
.status-value.offline { color: #e17055; }
.battery-bar {
flex: 1;
height: 16rpx;
background: #dfe6e9;
border-radius: 8rpx;
overflow: hidden;
margin-left: 20rpx;
}
.battery-fill {
height: 100%;
background: linear-gradient(to right, #00b894, #00cec9);
border-radius: 8rpx;
transition: width 0.5s;
}
.control-btns {
display: flex;
gap: 20rpx;
margin-top: 20rpx;
}
.btn-control {
flex: 1;
background: #dfe6e9;
color: #2d3436;
border-radius: 40rpx;
font-size: 28rpx;
height: 70rpx;
line-height: 70rpx;
border: none;
}
.btn-control.active {
background: #e17055;
color: #fff;
}
/* 未绑定状态 */
.device-unbound {
padding: 20rpx 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
}
.empty-img {
width: 200rpx;
height: 200rpx;
margin-bottom: 30rpx;
}
.empty-text {
font-size: 28rpx;
color: #888;
margin-bottom: 30rpx;
}
.btn-bind {
background: #00b894;
color: #fff;
border-radius: 40rpx;
width: 400rpx;
height: 70rpx;
line-height: 70rpx;
font-size: 30rpx;
border: none;
margin-bottom: 20rpx;
}
.bind-hint {
font-size: 24rpx;
color: #b2bec3;
margin: 10rpx 0;
}
.device-input {
width: 400rpx;
height: 70rpx;
border: 2rpx solid #dfe6e9;
border-radius: 40rpx;
padding: 0 30rpx;
font-size: 28rpx;
margin-bottom: 20rpx;
}
/* 数据概览 */
.data-section {
background: #fff;
border-radius: 20rpx;
padding: 20rpx 30rpx 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
}
.data-grid {
display: flex;
justify-content: space-around;
padding: 10rpx 0;
}
.data-card {
text-align: center;
}
.data-number {
font-size: 48rpx;
font-weight: bold;
color: #00b894;
}
.data-label {
font-size: 24rpx;
color: #888;
margin-top: 8rpx;
}
/* 快捷入口 */
.quick-entry {
display: flex;
justify-content: space-around;
background: #fff;
border-radius: 20rpx;
padding: 30rpx 0;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
}
.entry-item {
display: flex;
flex-direction: column;
align-items: center;
}
.entry-item image {
width: 60rpx;
height: 60rpx;
margin-bottom: 10rpx;
}
.entry-item text {
font-size: 24rpx;
color: #2d3436;
}
6. pages/dashboard/dashboard - 数据看板
dashboard.wxml
<!-- pages/dashboard/dashboard.wxml -->
<view class="container">
<view class="header">
<text class="title"> 数据看板</text>
</view>
<!-- 统计卡片 -->
<view class="stats-grid">
<view class="stat-card">
<text class="stat-num">{{todayKill}}</text>
<text class="stat-label">今日击杀</text>
</view>
<view class="stat-card">
<text class="stat-num">{{weekKill}}</text>
<text class="stat-label">本周击杀</text>
</view>
<view class="stat-card">
<text class="stat-num">{{monthKill}}</text>
<text class="stat-label">本月击杀</text>
</view>
<view class="stat-card">
<text class="stat-num">{{totalKill}}</text>
<text class="stat-label">累计击杀</text>
</view>
</view>
<!-- 趋势图 -->
<view class="chart-section">
<view class="section-title"> 近30天趋势</view>
<view class="chart-container">
<canvas canvas-id="trendChart" class="chart-canvas"></canvas>
</view>
</view>
<!-- 历史记录 -->
<view class="history-section">
<view class="section-title"> 历史记录</view>
<view class="date-filter">
<picker mode="date" bindchange="onDateChange">
<text>{{selectedDate || '选择日期'}}</text>
</picker>
<button class="btn-filter" bindtap="loadHistory">查询</button>
</view>
<view class="history-list">
<view class="history-item" wx:for="{{historyList}}" wx:key="date">
<text class="history-date">{{item.date}}</text>
<text class="history-count">{{item.count}}只</text>
</view>
<view class="empty" wx:if="{{historyList.length === 0}}">
<text>暂无记录</text>
</view>
</view>
</view>
</view>
dashboard.js
// pages/dashboard/dashboard.js
import api from '../../utils/api';
Page({
data: {
todayKill: 0,
weekKill: 0,
monthKill: 0,
totalKill: 0,
selectedDate: '',
historyList: [],
trendData: []
},
onShow() {
this.loadStats();
this.loadTrend();
},
// 加载统计数据
async loadStats() {
wx.showLoading({ title: '加载中...' });
try {
const [today, total] = await Promise.all([
api.getTodayKill(),
api.getTotalKill()
]);
// 计算本周和本月击杀(需后端支持,此处模拟)
this.setData({
todayKill: today.count || 0,
weekKill: today.weekCount || 0,
monthKill: today.monthCount || 0,
totalKill: total.count || 0
});
} catch (err) {
console.error('加载数据失败:', err);
} finally {
wx.hideLoading();
}
},
// 加载趋势数据
async loadTrend() {
try {
const res = await api.getDailyTrend(30);
this.setData({ trendData: res || [] });
this.drawTrendChart();
} catch (err) {
console.error('加载趋势失败:', err);
}
},
// 绘制趋势图
drawTrendChart() {
const ctx = wx.createCanvasContext('trendChart');
const data = this.data.trendData;
const width = 350;
const height = 200;
const padding = { top: 20, bottom: 30, left: 30, right: 20 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
if (data.length === 0) {
ctx.setFontSize(14);
ctx.setFillStyle('#999');
ctx.fillText('暂无数据', width/2 - 30, height/2);
ctx.draw();
return;
}
const maxVal = Math.max(...data.map(d => d.count), 1);
const minVal = Math.min(...data.map(d => d.count), 0);
const range = maxVal - minVal || 1;
// 绘制网格线
ctx.setStrokeStyle('#e8e8e8');
ctx.setLineWidth(1);
for (let i = 0; i < 4; i++) {
const y = padding.top + i * chartHeight / 3;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
// 绘制折线
ctx.setStrokeStyle('#00b894');
ctx.setLineWidth(3);
ctx.beginPath();
data.forEach((item, index) => {
const x = padding.left + (index / (data.length - 1 || 1)) * chartWidth;
const y = padding.top + chartHeight - ((item.count - minVal) / range) * chartHeight;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// 绘制数据点
ctx.setFillStyle('#00b894');
data.forEach((item, index) => {
const x = padding.left + (index / (data.length - 1 || 1)) * chartWidth;
const y = padding.top + chartHeight - ((item.count - minVal) / range) * chartHeight;
ctx.beginPath();
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
});
ctx.draw();
},
// 日期选择
onDateChange(e) {
this.setData({ selectedDate: e.detail.value });
},
// 加载历史记录
async loadHistory() {
const date = this.data.selectedDate;
if (!date) {
wx.showToast({ title: '请选择日期', icon: 'none' });
return;
}
wx.showLoading({ title: '加载中...' });
try {
const res = await api.getHistoryKill({ date });
this.setData({ historyList: res || [] });
} catch (err) {
wx.showToast({ title: '加载失败', icon: 'none' });
} finally {
wx.hideLoading();
}
}
});
dashboard.wxss
/* pages/dashboard/dashboard.wxss */
.container {
padding: 20rpx 30rpx 100rpx;
background: #f5f7fa;
min-height: 100vh;
}
.header {
padding: 20rpx 0;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #2d3436;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
margin-bottom: 20rpx;
}
.stat-card {
background: #fff;
border-radius: 16rpx;
padding: 20rpx 0;
text-align: center;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
}
.stat-num {
font-size: 36rpx;
font-weight: bold;
color: #00b894;
display: block;
}
.stat-label {
font-size: 22rpx;
color: #888;
margin-top: 6rpx;
}
.chart-section,
.history-section {
background: #fff;
border-radius: 20rpx;
padding: 20rpx 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #2d3436;
margin-bottom: 16rpx;
}
.chart-canvas {
width: 100%;
height: 400rpx;
}
.date-filter {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 20rpx;
}
.date-filter picker {
flex: 1;
background: #f5f7fa;
border-radius: 40rpx;
padding: 16rpx 30rpx;
font-size: 28rpx;
}
.btn-filter {
background: #00b894;
color: #fff;
border-radius: 40rpx;
padding: 16rpx 40rpx;
font-size: 28rpx;
border: none;
}
.history-item {
display: flex;
justify-content: space-between;
padding: 20rpx 0;
border-bottom: 2rpx solid #f5f7fa;
}
.history-date {
font-size: 28rpx;
color: #2d3436;
}
.history-count {
font-size: 28rpx;
color: #00b894;
font-weight: 500;
}
.empty {
text-align: center;
padding: 40rpx 0;
color: #b2bec3;
font-size: 28rpx;
}
7. pages/rank/rank - 排行榜
rank.wxml
<!-- pages/rank/rank.wxml -->
<view class="container">
<view class="header">
<text class="title"> 排行榜</text>
</view>
<!-- Tab切换 -->
<view class="tabs">
<view class="tab-item {{currentTab === 'daily' ? 'active' : ''}}" bindtap="switchTab" data-tab="daily">
今日击杀
</view>
<view class="tab-item {{currentTab === 'monthly' ? 'active' : ''}}" bindtap="switchTab" data-tab="monthly">
本月击杀
</view>
</view>
<!-- 排名类型 -->
<view class="rank-types">
<view class="type-item {{rankType === 'global' ? 'active' : ''}}" bindtap="switchType" data-type="global">
全球榜
</view>
<view class="type-item {{rankType === 'region' ? 'active' : ''}}" bindtap="switchType" data-type="region">
区域榜
</view>
<view class="type-item {{rankType === 'friends' ? 'active' : ''}}" bindtap="switchType" data-type="friends">
好友榜
</view>
</view>
<!-- 我的排名 -->
<view class="my-rank" wx:if="{{myRank}}">
<view class="my-rank-left">
<text class="my-rank-label">我的排名</text>
<text class="my-rank-number">#{{myRank}}</text>
</view>
<view class="my-rank-right">
<text class="my-rank-count">{{myKillCount}}只</text>
</view>
</view>
<!-- 排行榜列表 -->
<view class="rank-list">
<view class="rank-item" wx:for="{{rankList}}" wx:key="rank">
<view class="rank-number {{item.rank <= 3 ? 'top' : ''}}">
{{item.rank}}
<text class="medal" wx:if="{{item.rank === 1}}">🥇</text>
<text class="medal" wx:elif="{{item.rank === 2}}">🥈</text>
<text class="medal" wx:elif="{{item.rank === 3}}">🥉</text>
</view>
<image class="rank-avatar" src="{{item.avatar || '/images/default_avatar.png'}}"></image>
<view class="rank-info">
<text class="rank-name">{{item.nickname}}</text>
<text class="rank-region">{{item.region || '未知地区'}}</text>
</view>
<view class="rank-count">{{item.killCount}}只</view>
</view>
</view>
<view class="load-more" bindtap="loadMore" wx:if="{{hasMore}}">
加载更多
</view>
</view>
rank.js
// pages/rank/rank.js
import api from '../../utils/api';
Page({
data: {
currentTab: 'daily',
rankType: 'global',
rankList: [],
myRank: null,
myKillCount: 0,
page: 1,
hasMore: true,
loading: false
},
onShow() {
this.loadRank();
},
// 切换Tab
switchTab(e) {
const tab = e.currentTarget.dataset.tab;
this.setData({
currentTab: tab,
page: 1,
rankList: [],
hasMore: true
});
this.loadRank();
},
// 切换排名类型
switchType(e) {
const type = e.currentTarget.dataset.type;
this.setData({
rankType: type,
page: 1,
rankList: [],
hasMore: true
});
this.loadRank();
},
// 加载排行榜
async loadRank() {
if (this.data.loading) return;
this.setData({ loading: true });
wx.showLoading({ title: '加载中...' });
try {
const { currentTab, rankType, page } = this.data;
let res;
if (rankType === 'global') {
res = await api.getGlobalRank(currentTab === 'daily' ? 'daily' : 'monthly');
} else if (rankType === 'region') {
res = await api.getRegionRank(currentTab === 'daily' ? 'daily' : 'monthly');
} else {
res = await api.getFriendRank();
}
const list = res.list || [];
this.setData({
rankList: page === 1 ? list : [...this.data.rankList, ...list],
myRank: res.myRank || null,
myKillCount: res.myKillCount || 0,
hasMore: res.hasMore || false,
page: page + 1
});
} catch (err) {
wx.showToast({ title: '加载失败', icon: 'none' });
} finally {
wx.hideLoading();
this.setData({ loading: false });
}
},
// 加载更多
loadMore() {
if (this.data.hasMore && !this.data.loading) {
this.loadRank();
}
}
});
rank.wxss
/* pages/rank/rank.wxss */
.container {
padding: 20rpx 30rpx 100rpx;
background: #f5f7fa;
min-height: 100vh;
}
.header {
padding: 20rpx 0;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #2d3436;
}
.tabs {
display: flex;
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
}
.tab-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 28rpx;
color: #888;
position: relative;
}
.tab-item.active {
color: #00b894;
font-weight: 600;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 6rpx;
background: #00b894;
border-radius: 4rpx;
}
.rank-types {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
}
.type-item {
padding: 16rpx 32rpx;
background: #fff;
border-radius: 40rpx;
font-size: 26rpx;
color: #888;
}
.type-item.active {
background: #00b894;
color: #fff;
}
.my-rank {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 16rpx;
padding: 24rpx 30rpx;
margin-bottom: 20rpx;
border-left: 8rpx solid #00b894;
}
.my-rank-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.my-rank-label {
font-size: 28rpx;
color: #888;
}
.my-rank-number {
font-size: 36rpx;
font-weight: bold;
color: #00b894;
}
.my-rank-count {
font-size: 30rpx;
font-weight: 600;
color: #2d3436;
}
.rank-list {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.rank-item {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
border-bottom: 2rpx solid #f5f7fa;
}
.rank-number {
width: 60rpx;
font-size: 28rpx;
color: #888;
text-align: center;
}
.rank-number.top {
font-weight: bold;
color: #2d3436;
}
.medal {
font-size: 32rpx;
margin-left: 4rpx;
}
.rank-avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
margin: 0 20rpx;
}
.rank-info {
flex: 1;
}
.rank-name {
font-size: 28rpx;
font-weight: 500;
color: #2d3436;
display: block;
}
.rank-region {
font-size: 22rpx;
color: #b2bec3;
}
.rank-count {
font-size: 28rpx;
font-weight: 600;
color: #00b894;
}
.load-more {
text-align: center;
padding: 30rpx;
color: #b2bec3;
font-size: 26rpx;
}
8. pages/achievements/achievements - 成就勋章墙
achievements.wxml
<!-- pages/achievements/achievements.wxml -->
<view class="container">
<view class="header">
<text class="title">️ 勋章墙</text>
<text class="subtitle">已获得 {{achievedCount}} / {{totalCount}} 枚勋章</text>
</view>
<!-- 进度条 -->
<view class="progress-bar">
<view class="progress-fill" style="width:{{achievedCount / totalCount * 100}}%"></view>
</view>
<!-- 勋章列表 -->
<view class="badge-grid">
<view class="badge-item {{item.unlocked ? '' : 'locked'}}"
wx:for="{{badgeList}}"
wx:key="id"
bindtap="showBadgeDetail"
data-id="{{item.id}}">
<view class="badge-icon">{{item.unlocked ? item.icon : ''}}</view>
<view class="badge-name">{{item.name}}</view>
<view class="badge-desc">{{item.description}}</view>
<view class="badge-status" wx:if="{{item.unlocked}}">
<text>✅ 已解锁</text>
<text class="badge-time">{{item.unlockedTime}}</text>
</view>
<view class="badge-status locked" wx:else>
<text> 未解锁</text>
<text class="badge-progress">进度: {{item.progress}}/{{item.target}}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty" wx:if="{{badgeList.length === 0}}">
<text>继续努力,解锁你的第一枚勋章!</text>
</view>
</view>
achievements.js
// pages/achievements/achievements.js
import api from '../../utils/api';
Page({
data: {
badgeList: [],
achievedCount: 0,
totalCount: 0
},
onShow() {
this.loadAchievements();
},
async loadAchievements() {
wx.showLoading({ title: '加载中...' });
try {
const res = await api.getAchievements();
// 预定义勋章配置
const badgeConfig = [
{ id: 'first_kill', name: '首杀', icon: '',
description: '完成第一次击杀', target: 1 },
{ id: 'hundred_kill', name: '百人斩', icon: '⚔️',
description: '累计击杀100只蚊子', target: 100 },
{ id: 'thousand_kill', name: '灭蚊大师', icon: '',
description: '累计击杀1000只蚊子', target: 1000 },
{ id: 'night_king', name: '夜战之王', icon: '',
description: '夜间击杀占比超过80%', target: 0.8 }
];
const badgeList = badgeConfig.map(config => {
const userData = res.find(item => item.id === config.id) || {};
return {
...config,
unlocked: userData.unlocked || false,
unlockedTime: userData.unlockedTime || '',
progress: userData.progress || 0,
target: config.target
};
});
const achievedCount = badgeList.filter(b => b.unlocked).length;
this.setData({
badgeList,
achievedCount,
totalCount: badgeList.length
});
} catch (err) {
wx.showToast({ title: '加载失败', icon: 'none' });
} finally {
wx.hideLoading();
}
},
showBadgeDetail(e) {
const id = e.currentTarget.dataset.id;
const badge = this.data.badgeList.find(b => b.id === id);
if (!badge) return;
wx.showModal({
title: badge.unlocked ? ' ' + badge.name : ' ' + badge.name,
content: badge.unlocked
? `${badge.description}n解锁时间: ${badge.unlockedTime}`
: `${badge.description}n进度: ${badge.progress}/${badge.target}`,
confirmText: badge.unlocked ? '太棒了' : '继续努力',
showCancel: false
});
}
});
achievements.wxss
/* pages/achievements/achievements.wxss */
.container {
padding: 20rpx 30rpx 100rpx;
background: #f5f7fa;
min-height: 100vh;
}
.header {
padding: 20rpx 0;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #2d3436;
display: block;
}
.subtitle {
font-size: 26rpx;
color: #888;
margin-top: 8rpx;
}
.progress-bar {
width: 100%;
height: 16rpx;
background: #dfe6e9;
border-radius: 8rpx;
overflow: hidden;
margin: 20rpx 0 30rpx;
}
.progress-fill {
height: 100%;
background: linear-gradient(to right, #fdcb6e, #e17055);
border-radius: 8rpx;
transition: width 0.5s;
}
.badge-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.badge-item {
background: #fff;
border-radius: 20rpx;
padding: 30rpx 20rpx;
text-align: center;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
transition: all 0.3s;
}
.badge-item.locked {
opacity: 0.5;
filter: grayscale(1);
}
.badge-icon {
font-size: 60rpx;
margin-bottom: 16rpx;
}
.badge-name {
font-size: 28rpx;
font-weight: 600;
color: #2d3436;
}
.badge-desc {
font-size: 22rpx;
color: #888;
margin: 8rpx 0 16rpx;
}
.badge-status {
font-size: 22rpx;
color: #00b894;
}
.badge-status.locked {
color: #b2bec3;
}
.badge-time,
.badge-progress {
font-size: 20rpx;
color: #b2bec3;
display: block;
margin-top: 4rpx;
}
.empty {
text-align: center;
padding: 80rpx 0;
color: #b2bec3;
font-size: 28rpx;
}
9. pages/profile/profile - 个人中心
profile.wxml
<!-- pages/profile/profile.wxml -->
<view class="container">
<view class="user-card">
<image class="user-avatar" src="{{userInfo.avatarUrl || '/images/default_avatar.png'}}"></image>
<view class="user-info">
<text class="user-name">{{userInfo.nickName || '未登录'}}</text>
<text class="user-region">{{userInfo.region || '未知地区'}}</text>
</view>
<button class="btn-login" wx:if="{{!userInfo}}" bindtap="login">登录</button>
</view>
<view class="stats-summary">
<view class="stats-item">
<text class="stats-number">{{totalKill}}</text>
<text class="stats-label">总击杀</text>
</view>
<view class="stats-item">
<text class="stats-number">{{maxDailyKill}}</text>
<text class="stats-label">单日最高</text>
</view>
<view class="stats-item">
<text class="stats-number">{{rank}}</text>
<text class="stats-label">全球排名</text>
</view>
</view>
<view class="menu-list">
<view class="menu-item" bindtap="sharePoster">
<text> 生成分享海报</text>
<text class="menu-arrow">›</text>
</view>
<view class="menu-item" bindtap="viewHistory">
<text> 历史数据</text>
<text class="menu-arrow">›</text>
</view>
<view class="menu-item" bindtap="about">
<text>ℹ️ 关于</text>
<text class="menu-arrow">›</text>
</view>
<view class="menu-item" bindtap="logout" wx:if="{{userInfo}}">
<text> 退出登录</text>
<text class="menu-arrow">›</text>
</view>
</view>
</view>
profile.js
// pages/profile/profile.js
import api from '../../utils/api';
const app = getApp();
Page({
data: {
userInfo: null,
totalKill: 0,
maxDailyKill: 0,
rank: 0
},
onShow() {
this.loadUserInfo();
this.loadStats();
},
loadUserInfo() {
const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo');
this.setData({ userInfo });
},
async loadStats() {
try {
const [total, rank] = await Promise.all([
api.getTotalKill(),
api.getUserRank()
]);
this.setData({
totalKill: total.count || 0,
maxDailyKill: total.maxDaily || 0,
rank: rank.rank || 0
});
} catch (err) {
console.error('加载统计失败:', err);
}
},
login() {
app.login().then(userInfo => {
this.setData({ userInfo });
wx.showToast({ title: '登录成功', icon: 'success' });
}).catch(err => {
wx.showToast({ title: '登录失败', icon: 'none' });
});
},
logout() {
wx.showModal({
title: '确认退出',
content: '退出后需重新登录,确定继续?',
success: (res) => {
if (res.confirm) {
wx.removeStorageSync('userInfo');
app.globalData.userInfo = null;
this.setData({ userInfo: null });
wx.showToast({ title: '已退出', icon: 'success' });
}
}
});
},
sharePoster() {
wx.navigateTo({
url: `/pages/share/share?killCount=${this.data.totalKill}&rank=${this.data.rank}`
});
},
viewHistory() {
wx.switchTab({ url: '/pages/dashboard/dashboard' });
},
about() {
wx.showModal({
title: '关于智能电蚊拍',
content: '版本: V1.0nn挥拍灭蚊,全球竞技!n将传统的灭蚊体验与物联网、游戏化社交相结合,让每一次挥拍都充满乐趣。',
showCancel: false
});
}
});
10. pages/share/share - 分享海报生成
share.wxml
<!-- pages/share/share.wxml -->
<view class="container">
<view class="preview">
<canvas canvas-id="posterCanvas" class="poster-canvas"></canvas>
</view>
<view class="actions">
<button class="btn-save" bindtap="savePoster"> 保存到相册</button>
<button class="btn-share" open-type="share"> 分享给好友</button>
</view>
</view>
share.js
// pages/share/share.js
import api from '../../utils/api';
Page({
data: {
killCount: 0,
rank: 0,
userInfo: null
},
onLoad(options) {
this.setData({
killCount: parseInt(options.killCount) || 0,
rank: parseInt(options.rank) || 0
});
this.loadUserInfo();
},
onReady() {
this.generatePoster();
},
loadUserInfo() {
const app = getApp();
const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo');
this.setData({ userInfo });
},
async generatePoster() {
const ctx = wx.createCanvasContext('posterCanvas');
const width = 600;
const height = 800;
// 绘制背景
const grd = ctx.createLinearGradient(0, 0, 0, height);
grd.addColorStop(0, '#0a3d62');
grd.addColorStop(1, '#3c6382');
ctx.setFillStyle(grd);
ctx.fillRect(0, 0, width, height);
// 绘制标题
ctx.setFillStyle('#ffffff');
ctx.setFontSize(48);
ctx.setTextAlign('center');
ctx.fillText('🦟 智能电蚊拍', width / 2, 120);
// 绘制击杀数
ctx.setFontSize(120);
ctx.setFillStyle('#fdcb6e');
ctx.fillText(this.data.killCount + '只', width / 2, 320);
// 绘制排名
ctx.setFontSize(36);
ctx.setFillStyle('#dfe6e9');
ctx.fillText('全球排名 #' + (this.data.rank || '未上榜'), width / 2, 400);
// 绘制用户信息
ctx.setFontSize(28);
ctx.setFillStyle('#b2bec3');
const nickname = this.data.userInfo?.nickName || '神秘灭蚊侠';
ctx.fillText(' ' + nickname, width / 2, 480);
// 绘制标语
ctx.setFontSize(24);
ctx.setFillStyle('#81ecec');
ctx.fillText('挥拍灭蚊 · 全球竞技', width / 2, 550);
// 绘制装饰线
ctx.setStrokeStyle('#fdcb6e');
ctx.setLineWidth(2);
ctx.beginPath();
ctx.moveTo(100, 600);
ctx.lineTo(width - 100, 600);
ctx.stroke();
// 绘制底部文字
ctx.setFontSize(20);
ctx.setFillStyle('#636e72');
ctx.fillText('扫码加入灭蚊大战', width / 2, 670);
ctx.fillText('小程序码', width / 2, 710);
ctx.draw();
},
savePoster() {
wx.canvasToTempFilePath({
canvasId: 'posterCanvas',
success: (res) => {
wx.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
wx.showToast({ title: '保存成功', icon: 'success' });
},
fail: () => {
wx.showToast({ title: '保存失败', icon: 'none' });
}
});
}
});
},
onShareAppMessage() {
return {
title: `🦟 我用智能电蚊拍消灭了${this.data.killCount}只蚊子!你能超越我吗?`,
path: '/pages/index/index',
imageUrl: '' // 可传入海报图片
};
}
});
11. 云函数示例 - getAchievements
// cloudfunctions/getAchievements/index.js
const cloud = require('wx-server-sdk');
cloud.init();
const db = cloud.database();
exports.main = async (event, context) => {
const wxContext = cloud.getWXContext();
const openid = wxContext.OPENID;
try {
// 获取用户击杀统计
const userRes = await db.collection('users').where({
_openid: openid
}).get();
const user = userRes.data[0] || {};
const totalKill = user.totalKill || 0;
const nightKill = user.nightKill || 0;
const totalKillAll = user.totalKillAll || 0;
// 计算成就
const achievements = [];
// 首杀
achievements.push({
id: 'first_kill',
unlocked: totalKill >= 1,
progress: Math.min(totalKill, 1),
unlockedTime: user.firstKillTime || ''
});
// 百人斩
achievements.push({
id: 'hundred_kill',
unlocked: totalKill >= 100,
progress: Math.min(totalKill, 100),
unlockedTime: user.hundredKillTime || ''
});
// 灭蚊大师
achievements.push({
id: 'thousand_kill',
unlocked: totalKill >= 1000,
progress: Math.min(totalKill, 1000),
unlockedTime: user.thousandKillTime || ''
});
// 夜战之王 (夜间击杀占比超过80%)
const nightRate = totalKillAll > 0 ? nightKill / totalKillAll : 0;
achievements.push({
id: 'night_king',
unlocked: nightRate >= 0.8 && totalKillAll > 0,
progress: Math.round(nightRate * 100),
target: 80,
unlockedTime: user.nightKingTime || ''
});
return {
code: 0,
data: achievements
};
} catch (err) {
return {
code: -1,
message: err.message
};
}
};
12. 云函数 - getRank
// cloudfunctions/getRank/index.js
const cloud = require('wx-server-sdk');
cloud.init();
const db = cloud.database();
exports.main = async (event, context) => {
const wxContext = cloud.getWXContext();
const openid = wxContext.OPENID;
const { type = 'daily' } = event;
try {
// 获取所有用户按击杀数排序
const res = await db.collection('users')
.orderBy(type === 'daily' ? 'todayKill' : 'monthKill', 'desc')
.limit(100)
.get();
const list = res.data.map((user, index) => ({
rank: index + 1,
openid: user._openid,
nickname: user.nickName || '匿名用户',
avatar: user.avatarUrl || '',
region: user.region || '未知',
killCount: type === 'daily' ? (user.todayKill || 0) : (user.monthKill || 0)
}));
// 获取当前用户排名
const userRes = await db.collection('users').where({
_openid: openid
}).get();
let myRank = null;
let myKillCount = 0;
if (userRes.data.length > 0) {
const user = userRes.data[0];
myKillCount = type === 'daily' ? (user.todayKill || 0) : (user.monthKill || 0);
// 计算排名
const countRes = await db.collection('users')
.where({
[type === 'daily' ? 'todayKill' : 'monthKill']: db.command.gt(myKillCount)
})
.count();
myRank = countRes.total + 1;
}
return {
code: 0,
data: {
list,
myRank,
myKillCount,
hasMore: list.length >= 100
}
};
} catch (err) {
return {
code: -1,
message: err.message
};
}
};
13. utils/constants.js - 常量定义
// utils/constants.js
// 成就配置
export const ACHIEVEMENTS = {
FIRST_KILL: {
id: 'first_kill',
name: '首杀',
icon: '',
description: '完成第一次击杀',
target: 1
},
HUNDRED_KILL: {
id: 'hundred_kill',
name: '百人斩',
icon: '⚔️',
description: '累计击杀100只蚊子',
target: 100
},
THOUSAND_KILL: {
id: 'thousand_kill',
name: '灭蚊大师',
icon: '',
description: '累计击杀1000只蚊子',
target: 1000
},
NIGHT_KING: {
id: 'night_king',
name: '夜战之王',
icon: '',
description: '夜间击杀占比超过80%',
target: 80
}
};
// 排行榜类型
export const RANK_TYPES = {
DAILY: 'daily',
MONTHLY: 'monthly',
GLOBAL: 'global',
REGION: 'region',
FRIENDS: 'friends'
};
// MQTT主题
export const MQTT_TOPICS = {
STATUS: 'device/status',
KILL: 'device/kill',
CONTROL: 'device/control'
};
export default {
ACHIEVEMENTS,
RANK_TYPES,
MQTT_TOPICS
};
四、STM32代码设计
4.1 硬件连线说明
(1)电源及调试接口
| MCU引脚 | 功能 | 连接说明 |
|---|---|---|
| VDD(引脚19、32、48) | 3.3V供电 | 接系统3.3V电源 |
| VDDA(引脚13) | 模拟电源 | 接3.3V(需加滤波电容) |
| VSS(引脚18、31、47) | 数字地 | 接GND |
| VSSA(引脚12) | 模拟地 | 接GND(与数字地单点连接) |
| NRST(引脚7) | 复位 | 接复位按键及上拉电阻(10kΩ至3.3V) |
| BOOT0(引脚44) | 启动选择 | 接10kΩ下拉电阻到地(正常启动模式),预留跳线至3.3V用于ISP下载 |
| BOOT1(引脚20) | 启动选择 | 接10kΩ下拉电阻到地(正常启动模式) |
| PA13(引脚34)/ PA14(引脚37) | SWD调试接口 | 分别接SWDIO、SWCLK,各接10kΩ上拉电阻至3.3V |
(2)OLED显示屏(0.96寸,I2C接口)
OLED采用4线I2C接口(标准SSD1306驱动),连接如下:
| OLED引脚 | MCU引脚 | 功能说明 |
|---|---|---|
| VCC | 3.3V | 显示屏供电 |
| GND | GND | 地 |
| SCL | PB6(引脚42) | I2C1_SCL,时钟线(需接4.7kΩ上拉至3.3V) |
| SDA | PB7(引脚43) | I2C1_SDA,数据线(需接4.7kΩ上拉至3.3V) |
(3)4G通信模组(合宙Air780E)
Air780E与MCU通过串口通信,AT指令交互,连接如下:
| Air780E引脚 | MCU引脚 | 功能说明 |
|---|---|---|
| VCC(3.8V~4.2V) | 系统4V | 模组供电(需独立稳压,电流峰值约700mA) |
| GND | GND | 地 |
| UART_TX(主串口发送) | PA3(引脚13) | USART2_RX,MCU接收模组数据 |
| UART_RX(主串口接收) | PA2(引脚12) | USART2_TX,MCU发送AT指令至模组 |
| RESET | PA1(引脚11) | 模组硬件复位控制(低电平有效,接10kΩ上拉) |
| PWRKEY | PA0(引脚10) | 模组开机触发(低电平脉冲1.5秒以上) |
| NET_STATUS | PA4(引脚14) | 网络状态指示输入(检测模组联网状态,可选) |
(4)诱蚊灯控制
| 功能 | MCU引脚 | 连接说明 |
|---|---|---|
| LED驱动控制 | PB0(引脚25) | PWM输出或GPIO,驱动MOSFET(如SI2302)控制诱蚊灯(紫外LED)开关 |
| 诱蚊灯状态反馈 | PB1(引脚26) | 可选,检测LED驱动回路电流状态(ADC输入) |
(5)按键输入(诱蚊灯开关)
| 功能 | MCU引脚 | 连接说明 |
|---|---|---|
| 诱蚊灯按键 | PA5(引脚15) | GPIO输入(内部上拉,按键按下接GND,外接0.1μF电容滤波) |
(6)高压电网灭蚊检测与计数
| 功能 | MCU引脚 | 连接说明 |
|---|---|---|
| 电网放电检测 | PA6(引脚16) | GPIO外部中断输入(上升沿触发),通过光耦隔离电路(如PC817)检测高压电网放电脉冲,实现灭蚊计数 |
| 高压电路使能 | PA7(引脚17) | GPIO输出,控制高压升压电路使能(高电平有效) |
(7)语音播报模块
| 语音模块引脚 | MCU引脚 | 功能说明 |
|---|---|---|
| VCC | 3.3V/5V | 模块供电(视模块要求) |
| GND | GND | 地 |
| TX | PB10(引脚29) | USART3_RX(若为串口控制模块),发送控制指令 |
| RX | PB11(引脚30) | USART3_TX(若为串口控制模块),接收模块反馈 |
| BUSY | PB12(引脚31) | 可选,播放状态检测(高电平播放中) |
| SPK_OUT | - | 直接连接扬声器(模块自带功放) |
(8)外部FLASH存储器(
| FLASH引脚 | MCU引脚 | 功能说明 |
|---|---|---|
| CS | PB12(引脚31) | SPI2_CS |
| SCK | PB13(引脚32) | SPI2_SCK |
| MISO | PB14(引脚33) | SPI2_MISO |
| MOSI | PB15(引脚34) | SPI2_MOSI |
(9)锂电池电量检测(ADC)
| 功能 | MCU引脚 | 连接说明 |
|---|---|---|
| 电池电压检测 | PA4(引脚14) | ADC1_IN4,通过电阻分压网络(如100kΩ + 100kΩ)将锂电池电压(最高4.2V)分压至ADC量程(0~3.3V) |
(10)蜂鸣器/状态指示
| 功能 | MCU引脚 | 连接说明 |
|---|---|---|
| 工作状态LED | PC13(引脚2) | GPIO输出,指示系统运行状态(心跳闪烁) |
| 蜂鸣器 | PB8(引脚27) | PWM输出驱动蜂鸣器,用于简单提示音(如需) |
4.2 项目main.c设计
/**
* *****************************************************************************
* @file main.c
* @brief 户外便携式智能电蚊拍 - 主程序
* @version V1.0
* @date 2026-06-27
* @note 基于STM32F103C8T6 + Air780E + 华为云IoT
* *****************************************************************************
*/
#include "stm32f10x.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
/* ===================== 用户配置宏定义 ===================== */
#define OLED_I2C_ADDR 0x78 // OLED从机地址
#define FLASH_SPI_CS_PIN GPIO_Pin_12
#define FLASH_SPI_CS_PORT GPIOB
#define KEY_LED_PIN GPIO_Pin_5
#define KEY_LED_PORT GPIOA
#define LED_TRAP_PIN GPIO_Pin_0
#define LED_TRAP_PORT GPIOB
#define HV_ENABLE_PIN GPIO_Pin_7
#define HV_ENABLE_PORT GPIOA
#define KILL_DETECT_PIN GPIO_Pin_6
#define KILL_DETECT_PORT GPIOA
#define BAT_ADC_PIN GPIO_Pin_4
#define BAT_ADC_PORT GPIOA
#define STATUS_LED_PIN GPIO_Pin_13
#define STATUS_LED_PORT GPIOC
#define USART2_BAUD 115200 // Air780E通信波特率
#define USART3_BAUD 9600 // 语音模块波特率
#define KILL_VOICE_INTERVAL 10 // 每击杀10只播报一次
/* ===================== 数据结构定义 ===================== */
typedef struct {
uint16_t todayKill; // 今日灭蚊数
uint32_t totalKill; // 历史总灭蚊数
uint8_t ledTrapState; // 诱蚊灯状态: 0-关, 1-开
uint8_t batteryPercent; // 电池百分比 0-100
uint8_t hour; // 时 (0-23)
uint8_t minute; // 分 (0-59)
uint8_t second; // 秒 (0-59)
uint8_t day; // 日
uint8_t month; // 月
uint16_t year; // 年
char weather[32]; // 天气描述
char temperature[8]; // 温度
int8_t rssi; // 4G信号强度
uint8_t cloudConnected; // 云端连接状态
} SystemData_t;
typedef struct {
uint32_t killCount; // 击杀数
uint8_t hour; // 击杀时的小时
uint8_t minute; // 击杀时的分钟
uint8_t day;
uint8_t month;
uint16_t year;
} KillRecord_t;
/* ===================== 全局变量 ===================== */
SystemData_t g_sysData = {0};
KillRecord_t g_killRecord = {0};
volatile uint32_t systickCounter = 0;
volatile uint8_t killDetectFlag = 0;
volatile uint8_t keyPressFlag = 0;
volatile uint8_t oneSecondFlag = 0;
volatile uint32_t g_tickCount = 0;
uint8_t g_uart2RxBuf[256];
uint8_t g_uart2RxLen = 0;
uint8_t g_uart2RxComplete = 0;
uint8_t g_uart3RxBuf[64];
uint8_t g_uart3RxLen = 0;
uint8_t g_cloudTxBuf[512];
uint8_t g_localKillBuf[1024]; // 本地缓存击杀记录(用于断网续传)
uint32_t g_localKillCount = 0;
/* ===================== 函数声明 ===================== */
static void RCC_Config(void);
static void GPIO_Config(void);
static void USART2_Config(void);
static void USART3_Config(void);
static void I2C1_Config(void);
static void SPI2_Config(void);
static void ADC1_Config(void);
static void TIM4_Config(void);
static void NVIC_Config(void);
static void SysTick_Config(void);
static void EXTI_Config(void);
static void OLED_Init(void);
static void OLED_Update(void);
static void OLED_ShowChar(uint8_t x, uint8_t y, char c);
static void OLED_ShowString(uint8_t x, uint8_t y, char *str);
static void OLED_ShowNum(uint8_t x, uint8_t y, uint32_t num, uint8_t len);
static void OLED_Clear(void);
static void I2C_WriteByte(uint8_t addr, uint8_t reg, uint8_t data);
static void I2C_WriteMulti(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len);
static uint8_t SPI_ReadByte(void);
static void SPI_WriteByte(uint8_t data);
static void W25Q_Init(void);
static void W25Q_WritePage(uint32_t addr, uint8_t *data, uint16_t len);
static void W25Q_ReadData(uint32_t addr, uint8_t *data, uint16_t len);
static void W25Q_SectorErase(uint32_t addr);
static uint16_t ADC_Read(uint8_t channel);
static uint8_t GetBatteryPercent(void);
static void SaveKillToFlash(KillRecord_t *record);
static void UploadKillToCloud(KillRecord_t *record);
static void CheckAndUploadPendingData(void);
static void Air780E_Init(void);
static void Air780E_SendAT(char *cmd);
static uint8_t Air780E_GetLBS(void);
static uint8_t Air780E_GetWeather(void);
static uint8_t Air780E_MQTTConnect(void);
static void Air780E_MQTTPublish(char *topic, char *payload);
static int8_t Air780E_GetCSQ(void);
static void Voice_Play(uint8_t index);
static void Voice_PlayKillCount(uint32_t count);
static void RTC_GetDateTime(void);
static void RTC_SetDateTime(uint8_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second);
static void System_TimeUpdate(void);
/* ===================== 硬件初始化 ===================== */
/**
* @brief RCC时钟配置 - 系统时钟72MHz
*/
static void RCC_Config(void)
{
ErrorStatus HSEStartUpStatus;
RCC_DeInit();
RCC_HSEConfig(RCC_HSE_ON);
HSEStartUpStatus = RCC_WaitForHSEStartUp();
if (HSEStartUpStatus == SUCCESS)
{
RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9);
RCC_PLLCmd(ENABLE);
while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET);
RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK);
while (RCC_GetSYSCLKSource() != 0x08);
}
RCC_HCLKConfig(RCC_SYSCLK_Div1);
RCC_PCLK1Config(RCC_HCLK_Div2);
RCC_PCLK2Config(RCC_HCLK_Div1);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB |
RCC_APB2Periph_GPIOC | RCC_APB2Periph_AFIO |
RCC_APB2Periph_USART1 | RCC_APB2Periph_ADC1 |
RCC_APB2Periph_SPI1, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2 | RCC_APB1Periph_USART3 |
RCC_APB1Periph_I2C1 | RCC_APB1Periph_SPI2 |
RCC_APB1Periph_TIM4 | RCC_APB1Periph_BKP |
RCC_APB1Periph_PWR, ENABLE);
}
/**
* @brief GPIO初始化
*/
static void GPIO_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
// 诱蚊灯按键 - PA5 输入上拉
GPIO_InitStructure.GPIO_Pin = KEY_LED_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(KEY_LED_PORT, &GPIO_InitStructure);
// 诱蚊灯控制 - PB0 推挽输出
GPIO_InitStructure.GPIO_Pin = LED_TRAP_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(LED_TRAP_PORT, &GPIO_InitStructure);
GPIO_ResetBits(LED_TRAP_PORT, LED_TRAP_PIN);
// 高压使能 - PA7 推挽输出
GPIO_InitStructure.GPIO_Pin = HV_ENABLE_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(HV_ENABLE_PORT, &GPIO_InitStructure);
GPIO_ResetBits(HV_ENABLE_PORT, HV_ENABLE_PIN);
// 灭蚊检测 - PA6 浮空输入 (外部中断)
GPIO_InitStructure.GPIO_Pin = KILL_DETECT_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(KILL_DETECT_PORT, &GPIO_InitStructure);
// 状态指示灯 - PC13 推挽输出
GPIO_InitStructure.GPIO_Pin = STATUS_LED_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(STATUS_LED_PORT, &GPIO_InitStructure);
GPIO_SetBits(STATUS_LED_PORT, STATUS_LED_PIN);
}
/**
* @brief USART2初始化 - 连接Air780E
*/
static void USART2_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
USART_InitTypeDef USART_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; // TX
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; // RX
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
USART_InitStructure.USART_BaudRate = USART2_BAUD;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART2, &USART_InitStructure);
USART_ITConfig(USART2, USART_IT_RXNE, ENABLE);
USART_Cmd(USART2, ENABLE);
}
/**
* @brief USART3初始化 - 连接语音模块
*/
static void USART3_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
USART_InitTypeDef USART_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; // TX
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; // RX
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOB, &GPIO_InitStructure);
USART_InitStructure.USART_BaudRate = USART3_BAUD;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART3, &USART_InitStructure);
USART_ITConfig(USART3, USART_IT_RXNE, ENABLE);
USART_Cmd(USART3, ENABLE);
}
/**
* @brief I2C1初始化 - 连接OLED
*/
static void I2C1_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
I2C_InitTypeDef I2C_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;
I2C_InitStructure.I2C_OwnAddress1 = 0x00;
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
I2C_InitStructure.I2C_ClockSpeed = 100000;
I2C_Init(I2C1, &I2C_InitStructure);
I2C_Cmd(I2C1, ENABLE);
}
/**
* @brief SPI2初始化 - 连接W25Q FLASH
*/
static void SPI2_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
SPI_InitTypeDef SPI_InitStructure;
// CS - PB12 (软件控制)
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
GPIO_SetBits(GPIOB, GPIO_Pin_12);
// SCK - PB13, MISO - PB14, MOSI - PB15
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;
SPI_InitStructure.SPI_Mode = SPI_Mode_Master;
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;
SPI_InitStructure.SPI_CPOL = SPI_CPOL_High;
SPI_InitStructure.SPI_CPHA = SPI_CPHA_2Edge;
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8;
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;
SPI_InitStructure.SPI_CRCPolynomial = 7;
SPI_Init(SPI2, &SPI_InitStructure);
SPI_Cmd(SPI2, ENABLE);
}
/**
* @brief ADC1初始化 - 电池电压检测
*/
static void ADC1_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
ADC_InitTypeDef ADC_InitStructure;
GPIO_InitStructure.GPIO_Pin = BAT_ADC_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
GPIO_Init(BAT_ADC_PORT, &GPIO_InitStructure);
ADC_DeInit(ADC1);
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
ADC_InitStructure.ADC_ScanConvMode = DISABLE;
ADC_InitStructure.ADC_ContinuousConvMode = ENABLE;
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStructure.ADC_NbrOfChannel = 1;
ADC_Init(ADC1, &ADC_InitStructure);
ADC_RegularChannelConfig(ADC1, ADC_Channel_4, 1, ADC_SampleTime_239Cycles5);
ADC_Cmd(ADC1, ENABLE);
ADC_ResetCalibration(ADC1);
while (ADC_GetResetCalibrationStatus(ADC1));
ADC_StartCalibration(ADC1);
while (ADC_GetCalibrationStatus(ADC1));
}
/**
* @brief TIM4定时器配置 - 用于按键扫描和系统节拍
*/
static void TIM4_Config(void)
{
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
NVIC_InitTypeDef NVIC_InitStructure;
TIM_TimeBaseStructure.TIM_Period = 1000 - 1; // 1ms中断
TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1;
TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure);
TIM_ITConfig(TIM4, TIM_IT_Update, ENABLE);
NVIC_InitStructure.NVIC_IRQChannel = TIM4_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
TIM_Cmd(TIM4, ENABLE);
}
/**
* @brief 外部中断配置 - 灭蚊检测
*/
static void EXTI_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
EXTI_InitTypeDef EXTI_InitStructure;
NVIC_InitTypeDef NVIC_InitStructure;
// 使用PA6作为外部中断输入
GPIO_InitStructure.GPIO_Pin = KILL_DETECT_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(KILL_DETECT_PORT, &GPIO_InitStructure);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource6);
EXTI_InitStructure.EXTI_Line = EXTI_Line6;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising;
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
NVIC_InitStructure.NVIC_IRQChannel = EXTI9_5_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
/**
* @brief NVIC中断优先级配置
*/
static void NVIC_Config(void)
{
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
}
/**
* @brief SysTick配置
*/
static void SysTick_Config(void)
{
SysTick_Config(SystemCoreClock / 1000); // 1ms中断
}
/* ===================== OLED驱动 (I2C) ===================== */
#define OLED_WIDTH 128
#define OLED_HEIGHT 64
static uint8_t oledBuffer[OLED_WIDTH * OLED_HEIGHT / 8];
static void I2C_Start(void)
{
I2C_GenerateSTART(I2C1, ENABLE);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));
}
static void I2C_Stop(void)
{
I2C_GenerateSTOP(I2C1, ENABLE);
}
static void I2C_SendByte(uint8_t byte)
{
I2C_SendData(I2C1, byte);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));
}
static void I2C_WriteByte(uint8_t addr, uint8_t reg, uint8_t data)
{
I2C_Start();
I2C_SendByte(addr);
I2C_SendByte(reg);
I2C_SendByte(data);
I2C_Stop();
}
static void I2C_WriteMulti(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len)
{
uint16_t i;
I2C_Start();
I2C_SendByte(addr);
I2C_SendByte(reg);
for (i = 0; i < len; i++) {
I2C_SendByte(data[i]);
}
I2C_Stop();
}
static void OLED_WriteCmd(uint8_t cmd)
{
I2C_WriteByte(OLED_I2C_ADDR, 0x00, cmd);
}
static void OLED_WriteData(uint8_t data)
{
I2C_WriteByte(OLED_I2C_ADDR, 0x40, data);
}
static void OLED_WriteDataBuf(uint8_t *data, uint16_t len)
{
I2C_WriteMulti(OLED_I2C_ADDR, 0x40, data, len);
}
static void OLED_Init(void)
{
delay_ms(100);
OLED_WriteCmd(0xAE); // 关闭显示
OLED_WriteCmd(0xD5); // 设置时钟分频
OLED_WriteCmd(0x80);
OLED_WriteCmd(0xA8); // 设置多路复用
OLED_WriteCmd(0x3F);
OLED_WriteCmd(0xD3); // 设置显示偏移
OLED_WriteCmd(0x00);
OLED_WriteCmd(0x40); // 设置起始行
OLED_WriteCmd(0x8D); // 电荷泵设置
OLED_WriteCmd(0x14);
OLED_WriteCmd(0x20); // 设置内存寻址模式
OLED_WriteCmd(0x02);
OLED_WriteCmd(0xA1); // 段重映射
OLED_WriteCmd(0xC8); // 列扫描方向
OLED_WriteCmd(0xDA); // 设置COM引脚
OLED_WriteCmd(0x12);
OLED_WriteCmd(0x81); // 对比度设置
OLED_WriteCmd(0xCF);
OLED_WriteCmd(0xD9); // 预充电周期
OLED_WriteCmd(0xF1);
OLED_WriteCmd(0xDB); // VCOM检测
OLED_WriteCmd(0x40);
OLED_WriteCmd(0xA4); // 全屏打开
OLED_WriteCmd(0xA6); // 正常显示(非反色)
OLED_WriteCmd(0x2E); // 停止滚动
OLED_WriteCmd(0xAF); // 开启显示
OLED_Clear();
}
static void OLED_SetCursor(uint8_t page, uint8_t column)
{
OLED_WriteCmd(0xB0 + page);
OLED_WriteCmd(0x00 + (column & 0x0F));
OLED_WriteCmd(0x10 + ((column >> 4) & 0x0F));
}
static void OLED_Clear(void)
{
uint8_t i, j;
for (i = 0; i < 8; i++) {
OLED_SetCursor(i, 0);
for (j = 0; j < 128; j++) {
OLED_WriteData(0x00);
}
}
memset(oledBuffer, 0, sizeof(oledBuffer));
}
static void OLED_Update(void)
{
uint8_t i, j;
for (i = 0; i < 8; i++) {
OLED_SetCursor(i, 0);
for (j = 0; j < 128; j++) {
OLED_WriteData(oledBuffer[i * 128 + j]);
}
}
}
// 简易5x7点阵字库 (ASCII 32-127)
static const uint8_t Font5x7[][5] = {
{0x00,0x00,0x00,0x00,0x00}, // 空格
{0x00,0x00,0x5F,0x00,0x00}, // !
// ... (此处省略完整字库,实际使用时需补充完整)
// 为简化代码,仅提供几个常用字符
};
static void OLED_ShowChar(uint8_t x, uint8_t y, char c)
{
// 简单实现,实际需根据字库绘制
// 此处省略详细绘制代码,仅作示意
}
static void OLED_ShowString(uint8_t x, uint8_t y, char *str)
{
while (*str) {
// 逐字符显示,x+=6
str++;
}
}
static void OLED_ShowNum(uint8_t x, uint8_t y, uint32_t num, uint8_t len)
{
char buf[16];
sprintf(buf, "%0*d", len, num);
OLED_ShowString(x, y, buf);
}
/**
* @brief 显示主界面信息
*/
static void OLED_DisplayMain(void)
{
char buf[32];
uint8_t page = 0;
uint8_t col = 0;
// 清屏
OLED_Clear();
// 第1行: 日期时间
sprintf(buf, "%04d-%02d-%02d %02d:%02d",
g_sysData.year, g_sysData.month, g_sysData.day,
g_sysData.hour, g_sysData.minute);
OLED_ShowString(0, 0, buf);
// 第2行: 电池电量 + 诱蚊灯状态
sprintf(buf, "Bat:%d%% LED:%s", g_sysData.batteryPercent,
g_sysData.ledTrapState ? "ON " : "OFF");
OLED_ShowString(0, 2, buf);
// 第3行: 今日灭蚊
sprintf(buf, "Today:%d", g_sysData.todayKill);
OLED_ShowString(0, 4, buf);
// 第4行: 历史总数
sprintf(buf, "Total:%d", g_sysData.totalKill);
OLED_ShowString(0, 6, buf);
// 第5行: 天气信息
if (strlen(g_sysData.weather) > 0) {
sprintf(buf, "Wth:%s %sC", g_sysData.weather, g_sysData.temperature);
OLED_ShowString(0, 8, buf);
}
// 第6行: 4G信号 + 云端状态
sprintf(buf, "CSQ:%d Cloud:%s", g_sysData.rssi,
g_sysData.cloudConnected ? "OK" : "NO");
OLED_ShowString(0, 10, buf);
}
/* ===================== ADC驱动 ===================== */
static uint16_t ADC_Read(uint8_t channel)
{
ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_239Cycles5);
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC));
return ADC_GetConversionValue(ADC1);
}
/**
* @brief 获取电池百分比
*/
static uint8_t GetBatteryPercent(void)
{
uint16_t adcVal = 0;
uint32_t sum = 0;
float voltage;
for (int i = 0; i < 10; i++) {
sum += ADC_Read(ADC_Channel_4);
delay_ms(5);
}
adcVal = sum / 10;
// ADC参考电压3.3V, 分压比1/2, 12位ADC
voltage = (adcVal / 4096.0f) * 3.3f * 2.0f;
// 锂电池: 3.0V ~ 4.2V 对应 0% ~ 100%
if (voltage >= 4.2f) return 100;
if (voltage <= 3.0f) return 0;
return (uint8_t)((voltage - 3.0f) / 1.2f * 100);
}
/* ===================== SPI FLASH驱动 (W25Q) ===================== */
static void FLASH_CS_Low(void)
{
GPIO_ResetBits(FLASH_SPI_CS_PORT, FLASH_SPI_CS_PIN);
}
static void FLASH_CS_High(void)
{
GPIO_SetBits(FLASH_SPI_CS_PORT, FLASH_SPI_CS_PIN);
}
static uint8_t SPI_ReadWriteByte(uint8_t data)
{
while (SPI_I2S_GetFlagStatus(SPI2, SPI_I2S_FLAG_TXE) == RESET);
SPI_I2S_SendData(SPI2, data);
while (SPI_I2S_GetFlagStatus(SPI2, SPI_I2S_FLAG_RXNE) == RESET);
return SPI_I2S_ReceiveData(SPI2);
}
static void W25Q_Init(void)
{
FLASH_CS_High();
delay_ms(10);
}
static void W25Q_WriteEnable(void)
{
FLASH_CS_Low();
SPI_ReadWriteByte(0x06); // Write Enable
FLASH_CS_High();
delay_ms(1);
}
static void W25Q_WaitBusy(void)
{
uint8_t status;
do {
FLASH_CS_Low();
SPI_ReadWriteByte(0x05); // Read Status Register
status = SPI_ReadWriteByte(0xFF);
FLASH_CS_High();
} while (status & 0x01);
}
static void W25Q_SectorErase(uint32_t addr)
{
W25Q_WriteEnable();
FLASH_CS_Low();
SPI_ReadWriteByte(0x20); // Sector Erase (4KB)
SPI_ReadWriteByte((addr >> 16) & 0xFF);
SPI_ReadWriteByte((addr >> 8) & 0xFF);
SPI_ReadWriteByte(addr & 0xFF);
FLASH_CS_High();
W25Q_WaitBusy();
}
static void W25Q_WritePage(uint32_t addr, uint8_t *data, uint16_t len)
{
uint16_t i;
W25Q_WriteEnable();
FLASH_CS_Low();
SPI_ReadWriteByte(0x02); // Page Program
SPI_ReadWriteByte((addr >> 16) & 0xFF);
SPI_ReadWriteByte((addr >> 8) & 0xFF);
SPI_ReadWriteByte(addr & 0xFF);
for (i = 0; i < len; i++) {
SPI_ReadWriteByte(data[i]);
}
FLASH_CS_High();
W25Q_WaitBusy();
}
static void W25Q_ReadData(uint32_t addr, uint8_t *data, uint16_t len)
{
uint16_t i;
FLASH_CS_Low();
SPI_ReadWriteByte(0x03); // Read Data
SPI_ReadWriteByte((addr >> 16) & 0xFF);
SPI_ReadWriteByte((addr >> 8) & 0xFF);
SPI_ReadWriteByte(addr & 0xFF);
for (i = 0; i < len; i++) {
data[i] = SPI_ReadWriteByte(0xFF);
}
FLASH_CS_High();
}
/* ===================== 数据存储管理 ===================== */
#define FLASH_KILL_ADDR 0x00000000
#define FLASH_CONFIG_ADDR 0x00001000
static void SaveKillToFlash(KillRecord_t *record)
{
// 保存击杀记录到FLASH (简单覆盖写入)
W25Q_SectorErase(FLASH_KILL_ADDR);
W25Q_WritePage(FLASH_KILL_ADDR, (uint8_t*)record, sizeof(KillRecord_t));
}
static void LoadKillFromFlash(KillRecord_t *record)
{
W25Q_ReadData(FLASH_KILL_ADDR, (uint8_t*)record, sizeof(KillRecord_t));
// 简单校验: 如果读取无效则清零
if (record->year > 2030 || record->year < 2020) {
memset(record, 0, sizeof(KillRecord_t));
}
}
/**
* @brief 检查并上传待上传数据(断网续传)
*/
static void CheckAndUploadPendingData(void)
{
// 从FLASH读取待上传记录,逐条上传
// 实现略...
}
/* ===================== Air780E 4G模组驱动 ===================== */
static void USART2_SendByte(uint8_t byte)
{
while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET);
USART_SendData(USART2, byte);
}
static void USART2_SendString(char *str)
{
while (*str) {
USART2_SendByte(*str++);
}
}
static void USART2_SendAT(char *cmd)
{
USART2_SendString(cmd);
USART2_SendByte('r');
USART2_SendByte('n');
}
static void Air780E_Init(void)
{
// 拉低PWRKEY 1.5s 开机
GPIO_InitTypeDef GPIO_InitStructure;
// PA0作为PWRKEY输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_SetBits(GPIOA, GPIO_Pin_0);
delay_ms(100);
GPIO_ResetBits(GPIOA, GPIO_Pin_0);
delay_ms(2000);
GPIO_SetBits(GPIOA, GPIO_Pin_0);
// 等待模组启动完成 (约5-8秒)
delay_ms(5000);
// 发送AT测试指令
USART2_SendAT("AT");
delay_ms(500);
USART2_SendAT("ATE0"); // 回显关闭
delay_ms(500);
}
/**
* @brief 获取LBS基站定位
*/
static uint8_t Air780E_GetLBS(void)
{
// 发送 AT+CGATT? 检查网络附着
USART2_SendAT("AT+CGATT?");
delay_ms(500);
// 获取基站信息 AT+CEREG? 或 AT+QICSGP
USART2_SendAT("AT+CEREG?");
delay_ms(500);
// 实际解析串口数据获取经纬度(基站定位)
// 本例简化处理
return 1;
}
/**
* @brief 获取心知天气
*/
static uint8_t Air780E_GetWeather(void)
{
// 1. 先获取LBS位置
Air780E_GetLBS();
// 2. 通过HTTP GET请求心知天气API
// AT+HTTPGET=...
// 解析返回的JSON数据
// 模拟获取天气
strcpy(g_sysData.weather, "晴");
strcpy(g_sysData.temperature, "28");
return 1;
}
/**
* @brief MQTT连接华为云
*/
static uint8_t Air780E_MQTTConnect(void)
{
// 发送MQTT CONNECT报文
// 具体AT指令实现根据模组固件而定
// 以下为伪代码示意
USART2_SendAT("AT+MQTTCONN=broker.huaweicloud.com,1883,deviceid,password");
delay_ms(1000);
g_sysData.cloudConnected = 1; // 假设连接成功
return 1;
}
/**
* @brief 发布MQTT数据
*/
static void Air780E_MQTTPublish(char *topic, char *payload)
{
char cmd[256];
sprintf(cmd, "AT+MQTTPUB=%s,%s,0,0", topic, payload);
USART2_SendAT(cmd);
delay_ms(500);
}
/**
* @brief 获取信号强度
*/
static int8_t Air780E_GetCSQ(void)
{
USART2_SendAT("AT+CSQ");
delay_ms(300);
// 解析返回 +CSQ: <rssi>,<ber>
return 20; // 模拟值
}
/* ===================== 语音模块驱动 ===================== */
static void Voice_Play(uint8_t index)
{
// 通过USART3发送播放指令
char cmd[16];
sprintf(cmd, "PLAY%dn", index);
USART_SendString(USART3, cmd);
}
/**
* @brief 播放击杀数语音
*/
static void Voice_PlayKillCount(uint32_t count)
{
// 播报:"已击杀 XX 只"
Voice_Play(0x01); // 播放"已击杀"
delay_ms(500);
// 数字播报 (根据语音模块功能)
Voice_Play(0x02); // 播放数字
delay_ms(500);
Voice_Play(0x03); // 播放"只"
// 播放背景音乐
Voice_Play(0xFF); // 播放欢快BGM
}
/* ===================== RTC时间管理 ===================== */
static void RTC_GetDateTime(void)
{
// 使用STM32 RTC或从4G网络获取
// 本例通过4G网络获取时间 (AT+CCLK?)
USART2_SendAT("AT+CCLK?");
// 解析返回的时间字符串
// 模拟获取时间
g_sysData.year = 2026;
g_sysData.month = 6;
g_sysData.day = 27;
g_sysData.hour = 14;
g_sysData.minute = 30;
g_sysData.second = 0;
}
/* ===================== 系统核心功能 ===================== */
/**
* @brief 击杀处理
*/
static void HandleKillDetect(void)
{
// 检测到击杀
g_sysData.todayKill++;
g_sysData.totalKill++;
// 保存击杀记录 (用于上报)
KillRecord_t record;
record.killCount = g_sysData.totalKill;
record.hour = g_sysData.hour;
record.minute = g_sysData.minute;
record.day = g_sysData.day;
record.month = g_sysData.month;
record.year = g_sysData.year;
// 存储到本地FLASH(断网续传)
SaveKillToFlash(&record);
// 尝试上传到云端
if (g_sysData.cloudConnected) {
UploadKillToCloud(&record);
}
// 语音播报 (每10只整数倍)
if (g_sysData.totalKill % KILL_VOICE_INTERVAL == 0) {
Voice_PlayKillCount(g_sysData.totalKill);
}
}
/**
* @brief 上传击杀数据到云端
*/
static void UploadKillToCloud(KillRecord_t *record)
{
char json[128];
sprintf(json, "{"kill":%d,"time":"%04d-%02d-%02d %02d:%02d"}",
record->killCount, record->year, record->month, record->day,
record->hour, record->minute);
Air780E_MQTTPublish("device/kill", json);
}
/**
* @brief 按键处理 - 诱蚊灯开关
*/
static void HandleKeyPress(void)
{
// 切换诱蚊灯状态
g_sysData.ledTrapState = !g_sysData.ledTrapState;
if (g_sysData.ledTrapState) {
GPIO_SetBits(LED_TRAP_PORT, LED_TRAP_PIN);
GPIO_SetBits(HV_ENABLE_PORT, HV_ENABLE_PIN);
} else {
GPIO_ResetBits(LED_TRAP_PORT, LED_TRAP_PIN);
GPIO_ResetBits(HV_ENABLE_PORT, HV_ENABLE_PIN);
}
}
/**
* @brief 每秒定时任务
*/
static void OneSecondTask(void)
{
// 更新RTC时间
RTC_GetDateTime();
// 更新电池电量
g_sysData.batteryPercent = GetBatteryPercent();
// 更新信号强度
g_sysData.rssi = Air780E_GetCSQ();
// 检查云端连接
if (!g_sysData.cloudConnected) {
Air780E_MQTTConnect();
}
// 检查待上传数据
if (g_sysData.cloudConnected) {
CheckAndUploadPendingData();
}
// 更新OLED显示 (每1秒刷新)
OLED_DisplayMain();
// 心跳LED闪烁
GPIO_ToggleBits(STATUS_LED_PORT, STATUS_LED_PIN);
}
/* ===================== 中断服务函数 ===================== */
/**
* @brief SysTick中断 - 1ms系统节拍
*/
void SysTick_Handler(void)
{
systickCounter++;
if (systickCounter % 1000 == 0) {
oneSecondFlag = 1;
}
}
/**
* @brief 外部中断 - 灭蚊检测
*/
void EXTI9_5_IRQHandler(void)
{
if (EXTI_GetITStatus(EXTI_Line6) != RESET) {
// 消抖处理(简单延时)
delay_ms(10);
if (GPIO_ReadInputDataBit(KILL_DETECT_PORT, KILL_DETECT_PIN) == Bit_SET) {
killDetectFlag = 1;
}
EXTI_ClearITPendingBit(EXTI_Line6);
}
}
/**
* @brief TIM4中断 - 按键扫描 (每1ms)
*/
void TIM4_IRQHandler(void)
{
static uint16_t keyCount = 0;
if (TIM_GetITStatus(TIM4, TIM_IT_Update) != RESET) {
TIM_ClearITPendingBit(TIM4, TIM_IT_Update);
// 按键扫描 (20ms消抖)
if (GPIO_ReadInputDataBit(KEY_LED_PORT, KEY_LED_PIN) == Bit_RESET) {
keyCount++;
if (keyCount >= 20) {
keyPressFlag = 1;
keyCount = 0;
}
} else {
keyCount = 0;
}
}
}
/**
* @brief USART2中断 - 接收Air780E数据
*/
void USART2_IRQHandler(void)
{
if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) {
uint8_t data = USART_ReceiveData(USART2);
if (g_uart2RxLen < sizeof(g_uart2RxBuf) - 1) {
g_uart2RxBuf[g_uart2RxLen++] = data;
}
}
}
/**
* @brief USART3中断 - 接收语音模块数据
*/
void USART3_IRQHandler(void)
{
if (USART_GetITStatus(USART3, USART_IT_RXNE) != RESET) {
uint8_t data = USART_ReceiveData(USART3);
if (g_uart3RxLen < sizeof(g_uart3RxBuf) - 1) {
g_uart3RxBuf[g_uart3RxLen++] = data;
}
}
}
/* ===================== 延时函数 ===================== */
static void delay_ms(uint32_t ms)
{
uint32_t start = systickCounter;
while ((systickCounter - start) < ms);
}
/* ===================== 主函数 ===================== */
int main(void)
{
// 系统初始化
RCC_Config();
GPIO_Config();
NVIC_Config();
USART2_Config();
USART3_Config();
I2C1_Config();
SPI2_Config();
ADC1_Config();
TIM4_Config();
EXTI_Config();
SysTick_Config();
// OLED初始化
OLED_Init();
OLED_Clear();
OLED_ShowString(20, 3, "Smart Mosquito");
OLED_ShowString(30, 5, "Killer v1.0");
delay_ms(2000);
// FLASH初始化
W25Q_Init();
// 从FLASH加载历史数据
LoadKillFromFlash((KillRecord_t*)&g_sysData.totalKill);
// 如果今日首次启动,重置今日计数 (需判断日期)
// ...
// 初始化Air780E
Air780E_Init();
// 获取时间
RTC_GetDateTime();
// 获取天气预报
Air780E_GetWeather();
// 连接华为云MQTT
Air780E_MQTTConnect();
// 主循环
while (1) {
// 处理击杀检测
if (killDetectFlag) {
killDetectFlag = 0;
HandleKillDetect();
}
// 处理按键
if (keyPressFlag) {
keyPressFlag = 0;
HandleKeyPress();
}
// 每秒任务
if (oneSecondFlag) {
oneSecondFlag = 0;
OneSecondTask();
}
// 看门狗喂狗 (如需)
// IWDG_ReloadCounter();
// 低功耗处理或空循环
__WFI();
}
}
五、总结
这款户外便携式智能电蚊拍,本质上是对传统灭蚊工具的一次彻底重塑。它跳出了单一功能电器的局限,将物联网技术与户外生活场景深度融合,打造出一款既实用又充满趣味的智能终端。从底层架构来看,设备以STM32F103C8T6为核心,通过合宙Air780E 4G模组实现永远在线,依托华为云物联网平台完成数据流转,即便在无Wi-Fi的公园或野外,也能稳定地将每一次击杀记录同步至云端。
在用户体验层面,产品设计充分考虑了户外使用的真实需求。3000mAh锂电池配合IP5306电源管理方案,保障了长时间手持作战的续航能力;0.96寸OLED屏幕不仅是数据的窗口,更是交互的枢纽,从电池余量到基于LBS定位获取的当地天气预报,让用户在挥拍间隙对环境和设备状态了然于胸。而网球拍式的造型设计,既符合人体工学,又让挥动时的风阻更小,每一次击杀都伴随着真实的物理爽感。
真正让这款产品脱颖而出的,是其独特的“游戏化”内核。设备不再冰冷地执行任务,而是变成了陪伴用户的“狩猎伙伴”。每累计击杀十只蚊子,语音播报配合欢快音效即时响起,这种正向反馈机制极大地消解了户外防蚊的枯燥感。同时,本地Flash存储确保了4G信号不佳时的数据不丢失,待网络恢复后自动续传,体现了产品设计对现实复杂环境的周全考量。
而在社交维度,这款电蚊拍借助微信小程序构建了一个充满活力的“灭蚊宇宙”。用户不再是孤独的战斗者,而是全球排行榜上的一员。无论是“首杀”的纪念,还是“百人斩”的豪情,亦或是“夜战之王”的称号,成就系统精准捕捉了用户的炫耀心理。可分享的海报卡片,将个人的灭蚊战绩转化为社交货币,让产品在朋友圈中自发传播。这不仅仅是一个硬件项目,更是一个关于如何运用技术手段,将日常痛点转化为快乐社交体验的完整范本,为智能小家电的创新提供了一个极具参考价值的落地案例。
340