背景
随着科技水平的不断提高,生活水平的改善,智能家居产品正在不断走进日常生活。智能家居可以让家电更智能,更能让生活更舒适。智能家居也是互联网不断深化发展的产物。互联网连接的是人与人,进一步发展后形成物联网,连接人与物、物与物。
考虑到灯具与空调是使用最普遍的家电,可以从这两项入手做一套智能家居系统。
项目需求
设计并制作一套智能家居系统,可以完成对灯具和空调的控制。
- 具备在中控屏上控制灯具与空调的能力
- 具备在小程序中控制灯具与空调的能力
- 具备语音控制灯具的能力
整体方案
硬件上采用 RT1062 开发板做中控,使用一块 800x480 的屏幕提供人机交互,使用 E53 接口的 LED 灯扩展用于充当灯具,使用 esp8266 模块为 RT1062 提供联网能力。另外使用 esp8266 和红外发射模块作为独立的红外遥控器。整体硬件连接如下图所示:
软件上采用 TencentOS Tiny 提供任务调度能力与 mqtt 的连接能力,云端使用腾讯云 IoT 开发平台处理数据。小程序端绑定腾讯连连小程序,提供远程控制能力。
红外遥控能力由单独的 esp8266 模块提供,该模块在接收到局域网内的 UDP 数据包后解析成 json 格式的控制指令,再转为红外编码数据,最终通过红外发射模块将数据传送给空调。
整体软件框架如下图所示:
方案实施细节-驱动部分
基础驱动
基础驱动指的是随开发板例程改好的驱动,不在此项目的范围内,但该驱动比较重要。
首先是TencentOS Tiny 的移植。TencentOS Tiny 为物联网设备打造的轻量操作系统,可以通过裁剪配置运行在资源紧张的单片机上。移植该实时操作系统的主要步骤是产生系统节拍、任务切换、串口输出几个部分。
其次是使用外置 SDRAM。因为本项目需要较多的外部资源,开发板本身提供了 16MB 的 SDRAM,可以用于做显示缓存等。该部分的驱动通过 RT1602 的 OCD 模块写入单片机,无需业务代码,可以保证外置 SDRAM 能够在启动后就处于可用状态。
接着是 LCD 驱动。采用 RT1602 的增强型液晶接口外设 eLCDIF,以及库函数中的驱动程序,配合上文中已经可用的外置 SDRAM 作为显存,可以方便地驱动开发板自带的 800x480 液晶显示屏。
最后是语音驱动。将开发板 mic 的短路问题解决后,即可用例程初始化音频编解码芯片,并完成音频的输入输出。
GUI 驱动
开发板已经自带了 LCD 的驱动,但如果想做人机交互,仍需要移植对应的 GUI 驱动。本项目采用 lvgl 作为 GUI 绘图层,该部分代码从 TencentOS Tiny 的组件中获取。
触摸屏驱动
人机交互重要的部分是处理输入,开发板自带的屏幕中含有触摸屏,但没有适配触摸屏驱动。
触摸屏的芯片是 GT911, 支持最大 5 点触摸,与开发板通过 I2C 接口连接。在软件上考虑用软件模拟 I2C 的方式来快速实现驱动。
首先移植软件 I2C 的驱动。按照 I2C 的时序图完成对 GPIO 的控制,并对外暴露如下 api:
代码语言:javascript复制 void I2C_Send_Byte(uint8_t data);
uint8_t I2C_Read_Byte(unsigned char ack);
其次是移植 GT911 的驱动。该屏幕在出厂时已经写入了配置参数,所以无需做复杂配置,几乎上电后就可以用了。
触摸屏初始化完成后,每当有触摸事件发生时,都会通过 INT 引脚输出一个脉冲。开发板捕获这个脉冲的下降沿后,进入中断程序并置位触摸标志位。应用程序如果要判断触摸事件,先判断这个标志位,如果被置位,先复位,再去读触摸屏的寄存器,并最终获得触摸的位置。
中断处理代码如下:
代码语言:javascript复制 void GPIO5_Combined_0_15_IRQHandler(void) {
GPIO_PortClearInterruptFlags(DEV_INT_GPIO, 1U << DEV_INT_PIN);
Dev_Now.Touch = 1;
SDK_ISR_EXIT_BARRIER;
}
读取按键的代码如下:
代码语言:javascript复制void GT911_get_xy(int16_t* x, int16_t* y) {
uint8_t buf[41];
uint8_t Clearbuf = 0;
uint8_t i;
if (Dev_Now.Touch == 0) {
*x = 0;
*y = 0;
return;
}
Dev_Now.Touch = 0;
// 看有多少个点
GT911_RD_Reg(GT911_READ_XY_REG, buf, 1);
Dev_Now.TouchpointFlag = buf[0];
Dev_Now.TouchCount = buf[0] & 0x0f;
if (Dev_Now.TouchCount > 5 || Dev_Now.TouchCount == 0) {
GT911_WR_Reg(GT911_READ_XY_REG, (uint8_t *) & Clearbuf, 1);
tos_sleep_ms(10);
return;
}
// 这里只读第一个寄存器
GT911_WR_Reg(GT911_READ_XY_REG, (uint8_t *)&Clearbuf, 1);
tos_sleep_ms(10);
GT911_RD_Reg(GT911_READ_XY_REG 1, &buf[1], Dev_Now.TouchCount * 8);
GT911_WR_Reg(GT911_READ_XY_REG, (uint8_t *) & Clearbuf, 1);
tos_sleep_ms(10);
Dev_Now.Touchkeytrackid[0] = buf[1];
*x = ((uint16_t)buf[3] << 8) buf[2];
*y = ((uint16_t)buf[5] << 8) buf[4];
// 处理完后再置为 0
// 如果一开始就置为 0,中断随时发生,会被再次置为 1
Dev_Now.Touch = 0;
}
LVGL 移植
Lvgl 的移植包含几部分,输出设备的移植与输入设备的移植。这里的输出设备即 lcd 屏幕,输入设备即触摸屏。输出设备采取双缓冲区的方案,缓冲区大小与屏幕相同,可以降低驱动移植的复杂度。输入设备包含初始化与读取坐标。
将 lv_port_indev_template.c 复制为 lv_port_indev.c,并在 touchpad_init 里面初始化触摸屏:
代码语言:javascript复制static void touchpad_init(void)
{
GT911_Init();
}
随后在 touchpad_read 中读出相应的坐标
代码语言:javascript复制static bool touchpad_read(lv_indev_drv_t * indev_drv, lv_indev_data_t * data)
{
static lv_coord_t last_x = 0;
static lv_coord_t last_y = 0;
/*Save the pressed coordinates and the state*/
if(touchpad_is_pressed()) {
touchpad_get_xy(&last_x, &last_y);
data->state = LV_INDEV_STATE_PR;
} else {
data->state = LV_INDEV_STATE_REL;
}
/*Set the last pressed coordinates*/
data->point.x = last_x;
data->point.y = last_y;
/*Return `false` because we are not buffering and no more data to read*/
return false;
}
/*Return true is the touchpad is pressed*/
static bool touchpad_is_pressed(void) {
/*Your code comes here*/
return GT911_is_pressed();
}
/*Get the x and y coordinates if the touchpad is pressed*/
static void touchpad_get_xy(lv_coord_t * x, lv_coord_t * y) {
/*Your code comes here*/
GT911_get_xy(x, y);
}
将 lv_port_disp_template.c 复制为 lv_port_disp.c,并调用 lcd 的驱动即可,代码如下:
代码语言:javascript复制 static void disp_init(void)
{
/*You code here*/
lcd_init();
}
static void disp_flush(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
/*The most simple case (but also the slowest) to put all pixels to the screen one-by-one*/
// 此处直接设置 lcd 渲染下一帧
lcd_flush(color_p);
/* IMPORTANT!!!
* Inform the graphics library that you are ready with the flushing*/
lv_disp_flush_ready(disp_drv);
}
红外驱动
用来测试的空调是美的品牌,其控制方式有些特殊。先看红外数据编码。
基本编码格式为: L,A,A’,B,B’,C,C’, S, L,A,A’,B,B’,C,C’。
L为引导码;S为分隔码;A为识别码(A=10110010=0xB2,预留方案时A=10110111=0xB7),A’为A的反码;B’为B的反码;C’为C的反码。
引导码 L:
分割码:
数据 1:
数据 0:
终止符:
在进行普通风速、温度、模式控制时,B 和 C 的值按下图进行设置:
当关机的时候 B 固定为 0111 1011(0x7b),C 固定为 1110 0000(0xe0)
这里的红外数据数组是一个重要概念,它记录了一个红外数据帧的数据组成。数组的元素每两个成一组,表明低电平的持续时间、高电平的持续时间,时间单位是微秒。一个实例如下:
C unsigned short int ir_data = [4400, 4400, 540, 1620, 540, 540, ..., 540]; |
---|
前两个数据表示先持续 4.4ms 的低电平,再变为 4.4ms 的高电平,对比物理层协议得知这是一个起始位;接着持续 0.54ms 的低电平,1.62ms 的高电平,这是一个数据位 1;随后是一个数据位 0;最后一个 540 表示终止符。
红外遥控器是在另一块 esp8266 上实现的,实现方案采用 IRbaby 固件 https://github.com/Caffreyfans/IRbaby。因为美的空调需要特殊的红外数据,最终对 IRbaby 进行定制。在了解实现方式后,可以看到在 src/IRbabyIR.cpp中进行定制比较方便,注释掉ir_decode函数,再新编写generate_midea_ac函数替换其功能,得到红外数据数组即可。
方案实施细节-应用部分
在应用方面共分为四个任务,分别是联网任务、显示任务、音频采集任务和音频识别任务。代码如下:
代码语言:javascript复制int main(void) {
// 硬件初始化
BOARD_ConfigMPU();
BOARD_InitPins();
BOARD_BootClockRUN();
BOARD_InitDebugConsole();
AUDIO_Init();
PRINTF("Welcome to TencentOS tinyrn");
osKernelInitialize();
// 初始化信号量
tos_sem_create(&mic_data_ready_sem, 0);
// 语音识别任务
tos_task_create(&task1, "audio loop back task", audio_loop_back_task, NULL, 4, task1_stk, TASK1_STK_SIZE, 0);
tos_task_create(&task2, "clara detection task", clara_detection_task, NULL, 3, task2_stk, TASK2_STK_SIZE, 0);
// 连接腾讯云 IoT 平台
tos_task_create(&mqtt_task_t, "mqtt task", mqtt_task, NULL, 2, mqtt_task_stk, MQTT_STK_SIZE, 0);
// 显示任务
tos_task_create(&display_task_t, "display task", display_task, NULL, 4, display_task_stk, DISPLAY_STK_SIZE, 0);
// Clara引擎初始化
int ret = Clara_create(&clara_heap[0], MEM_POOL_SIZE, SAMPLES_PER_FRAME, DEFAULT_DURATION_IN_SEC_AFTER_WUW);
PRINTF("n%s init[%d]n", Clara_get_version(), ret);
PRINTF("rnClara wakeup example started!rn");
PRINTF("rnSay wakeup words: 1)Xiaozhi xiaozhi kai deng; 2)Xiaozhi xiaozhi guan deng; 3)Xiaozhi xiaozhi bian yan sern");
PRINTF("Trial version for 50 times wakeup testrn");
PRINTF("喊出唤醒词:1)小智小智开灯,2)小智小智关灯,3)小智小智变颜色rn");
PRINTF("50次唤醒试用版rnrn");
osKernelStart();
}
在完成开发板的初始化后,依次创建四个任务并执行任务调度。这里要注意任务的优先级,mqtt 的联网任务属于高优先级,设置为 2;音频采集和界面刷新都是需要周期执行的,优先级设置为 4;如果音频缓冲区满,应该优先做音频识别,所以音频识别任务的优先级设置为 3.
联网任务
任务的目的是通过 WIFI 接入腾讯云 IoT 开发平台的 mqtt 服务器,订阅开发板的 topic 并在开发板的属性变更时及时同步到云端。
接入 mqtt
esp8266 at 驱动部分已经由 TencentOS Tiny 完成,我们只需要按步骤接入腾讯云 IoT 开发平台即可。主要的工作流程如下
灯具控制
开发板附带了一个 E53 接口的 LED 灯,可以用来模拟灯具。灯具的控制是简单的开关量,在 IoT 开发平台新建卧室灯的数据模板,同时在小程序端新建灯具的控制组件,如下图所示。
回调函数的主要流程如下:
空调控制
在上文中已经解决了美的空调的底层控制逻辑,在应用部分主要解决用户界面以及小程序与智能中控的通信。
考虑空调的基本功能为开关 温度控制,所以新建一个开发量的数据模板和一个整数型的数据模板,其中空调温度要设置最低 16 最高 28。
回调函数的处理流程如下:
显示任务
本任务用于驱动 lvgl 的正常显示,同时绘制开关控件和微调器控件。
界面绘制
使用 lvgl 的控件库设计并制作了灯的开关、空调开关、空调温度控制三个控件。
关键代码如下所示:
代码语言:javascript复制// light
lv_obj_t* light_text = lv_label_create(lv_scr_act(), NULL);
lv_obj_align(light_text, NULL, LV_ALIGN_CENTER, -20, -20);
lv_label_set_text(light_text, "light");
light_sw = lv_sw_create(lv_scr_act(), NULL);
lv_obj_align(light_sw, light_text, LV_ALIGN_OUT_RIGHT_MID, 10, 0);
lv_obj_set_event_cb(light_sw, light_event_handler);
// ac
lv_obj_t* ac_text = lv_label_create(lv_scr_act(), NULL);
lv_obj_align(ac_text, NULL, LV_ALIGN_CENTER, -20, 20);
lv_label_set_text(ac_text, "ac_on");
ac_sw = lv_sw_create(lv_scr_act(), NULL);
lv_obj_align(ac_sw, ac_text, LV_ALIGN_OUT_RIGHT_MID, 8, 0);
lv_obj_set_event_cb(ac_sw, ac_event_handler);
// ac temp
spinbox = lv_spinbox_create(lv_scr_act(), NULL);
lv_spinbox_set_range(spinbox, 16, 28);
lv_spinbox_set_digit_format(spinbox, 2, 0);
lv_spinbox_step_prev(spinbox);
lv_obj_set_width(spinbox, 100);
lv_obj_align(spinbox, ac_text, LV_ALIGN_OUT_BOTTOM_MID, 20, 20);
lv_spinbox_step_next(spinbox);
实际展示效果如下图所示:
周期刷新
Lvgl 需要定期执行 lv_task_handler 函数,将其放入显示任务中。
代码语言:javascript复制void display_task(void *arg) {
PRINTF("begin init lcd...");
lv_init();
lv_port_disp_init();
lv_port_indev_init();
PRINTF("lcd init done.");
PRINTF("begin init touch screen");
GT911_Init();
PRINTF("init touch screen done");
demo_create();
while (1) {
lv_task_handler();
tos_sleep_ms(200);
}
}
同时,lvgl 还要求定期执行 lv_tick_inc 函数,将其放到 SysTick_Handler 中执行。
代码语言:javascript复制void SysTick_Handler(void)
{
if (tos_knl_is_running())
{
tos_knl_irq_enter();
tos_tick_handler();
tos_knl_irq_leave();
}
// lvgl 的 tick,注意要配置 systick 为 1ms
lv_tick_inc(1);
}
音频采集与识别任务
音频采集的目的是从编解码芯片中逐帧接收数据,处理后通知识别任务做识别。
代码语言:javascript复制void audio_loop_back_task(void *arg)
{
while (1) {
// 如果有 buffer 空闲,就开启 dma 传输任务
if (emptyBlock > 0) {
xfer.data = Buffer rx_index * BUFFER_SIZE;
xfer.dataSize = BUFFER_SIZE;
if (kStatus_Success == SAI_TransferReceiveEDMA(DEMO_SAI, &rxHandle, &xfer)) {
mic_frame_preprocess((uint16_t *)(xfer.data), xfer.dataSize/2);
tos_sem_post(&mic_data_ready_sem);
rx_index ;
}
if (rx_index == BUFFER_NUMBER) {
rx_index = 0U;
}
}
}
}
音频识别采用的是已训练好的 Clara 语音识别模型,如果匹配到对应的语音,会给出关键词 id。根据关键词 id 去控制灯的亮灭即可。
代码语言:javascript复制void clara_detection_task(void *arg)
{
extern uint16_t mic_16khz_buffer[];
int frame_cnt = 0;
int kw_total_num = 0;
int light_duration = 0;
int kw_detected = 0;
while(1)
{
tos_sem_pend(&mic_data_ready_sem, -1); //pend forever until mic audio ready
kw_detected = Clara_put_audio_two_phases(mic_16khz_buffer, SAMPLES_PER_FRAME, &clara_result);
if (kw_detected > 0)
{
kw_total_num ;
// check wakeup result
PRINTF("n[ClrDbg] Vox-AI: Clara got %d:%s (utf8 �) duration %d ms, score %d, conf %d, total %dn",
clara_result.kws_id, clara_result.kws, clara_result.kws_len,clara_result.duration,
clara_result.score, clara_result.confidence, kw_total_num);
if (clara_result.kws_id == 17) {
ctrl_light(1);
} else if (clara_result.kws_id == 18) {
ctrl_light(0);
}
}
}
}