分布式温度监控系统
分布式温度监控系统基于STM32系列芯片开发,支持采集多达6个分节点的温度数据,网关节点采集分节点的数据并通过WIFI上传云端远程实时监视,也可本地连接串口与PC端通讯,上位机实时显示分节点数据。
系统适用于家庭、办公室、教室等小面积场所的多点温度监控,无线传输距离可达 100m ~ 500m,具有功耗低,丢包率低,传输距离远等特点,是一个相当实用的 DIY 设计。
网关节点连接中国移动的 OneNet 云,PC 上网页登录 OneNet 即可实现远程监视分节点的温度数据,如下图所示。
移动端实现远程监视需要下载 OneNet 的官方 APP,登录账号即可查看设备数据。
网关节点通过串口连接 PC,并在上位机中打开串口即可实现本地监视节点数据。
Sensor框架对接
想要使用Sensor框架,需要在menuconfig中将它开启:
RT-Thread Components --->
Device Drivers --->
[*] Using Sensor device drivers
打开之后,需要使用 scons --target=mdk5 更新工程。看,Sensor 框架加入到工程当中
Sensor框架的接口分为上层接口和底层接口两种。将底层驱动对接到框架上,具体就是底层的ops接口。
在rt-thread\components\drivers\sensors中,我们可以找到ops接口的定义,有两个函数指针,fetch_data和control。
struct rt_sensor_ops{
rt_size_t (*fetch_data)(struct rt_sensor_device *sensor, void *buf, rt_size_t len);
rt_err_t (*control)(struct rt_sensor_device *sensor, int cmd, void *arg);
};
fetch_data 作用是获取传感器数据,control 作用是通过控制命令控制传感器,ds18b20 并不支持 control,我们只需要实现 fetch_data 就好了。
Sensor 框架当前默认支持轮询 (RT_DEVICE_FLAG_RDONLY)、中断 (RT_DEVICE_FLAG_INT_RX)、FIFO (RT_DEVICE_FLAG_FIFO_RX) 这三种打开方式。
需要在这里判断传感器的工作模式,然后再根据不同的模式返回传感器数据。我们以轮询的方式读取 ds18b20 的温度数据,那么 fetch_data 的实现如下:
static rt_size_t ds18b20_fetch_data(struct rt_sensor_device *sensor, void *buf, rt_size_t len)
{
RT_ASSERT(buf);
if (sensor->config.mode == RT_SENSOR_MODE_POLLING)
{
return _ds18b20_polling_get_data(sensor, buf);
}
else
return 0;
}
具体的,_ds18b20_polling_get_data(sensor, buf) 的实现如下,其中,ds18b20_get_temperature 函数就是 ds18b20 温度传感器底层驱动的获取温度的函数。
static rt_size_t _ds18b20_polling_get_data(rt_sensor_t sensor, struct rt_sensor_data *data)
{
rt_int32_t temperature_x10;
if (sensor->info.type == RT_SENSOR_CLASS_TEMP)
{
temperature_x10 = ds18b20_get_temperature((rt_base_t)sensor->config.intf.user_data);
data->data.temp = temperature_x10;
data->timestamp = rt_sensor_get_ts();
}
return 1;
}
因为不需要 control,我们直接让 control 返回 RT_EOK 即可
static rt_err_t ds18b20_control(struct rt_sensor_device *sensor, int cmd, void *args)
{
rt_err_t result = RT_EOK;
return result;
}
static struct rt_sensor_ops sensor_ops =
{
ds18b20_fetch_data,
ds18b20_control
};
传感器设备驱动
完成Sensor的ops对接之后还要注册一个Sensor设备,这样上层才能找到这个传感器设备,进而进行控制。
设备的注册需要以下几步:
创建一个 rt_sensor_t 的结构体指针
为结构体分配内存
完成相关初始化
int rt_hw_ds18b20_init(const char *name, struct rt_sensor_config *cfg)
{
rt_int8_t result;
rt_sensor_t sensor_temp = RT_NULL;
if (!ds18b20_init((rt_base_t)cfg->intf.user_data))
{
/* temperature sensor register */
sensor_temp = rt_calloc(1, sizeof(struct rt_sensor_device));
if (sensor_temp == RT_NULL)
return -1;
sensor_temp->info.type = RT_SENSOR_CLASS_TEMP;
sensor_temp->info.vendor = RT_SENSOR_VENDOR_DALLAS;
sensor_temp->info.model = "ds18b20";
sensor_temp->info.unit = RT_SENSOR_UNIT_DCELSIUS;
sensor_temp->info.intf_type = RT_SENSOR_INTF_ONEWIRE;
sensor_temp->info.range_max = SENSOR_TEMP_RANGE_MAX;
sensor_temp->info.range_min = SENSOR_TEMP_RANGE_MIN;
sensor_temp->info.period_min = 5;
rt_memcpy(&sensor_temp->config, cfg,
sizeof(struct rt_sensor_config));
sensor_temp->ops = &sensor_ops;
result = rt_hw_sensor_register(sensor_temp,
name,
RT_DEVICE_FLAG_RDONLY,
RT_NULL);
if (result != RT_EOK)
{
LOG_E("device register err code: %d", result);
goto __exit;
}
}
return RT_EOK;
__exit:
if (sensor_temp)
rt_free(sensor_temp);
return -RT_ERROR;
}
}
传感器设备注册第一步,创建一个rt_sensor_t的结构体指针。
rt_sensor_t sensor_temp = RT_NULL;
传感器设备注册的第二步:为结构体分配内存,上述代码中是这么实现的:
sensor_temp = rt_calloc(1, sizeof(struct rt_sensor_device));
if (sensor_temp == RT_NULL)
return -1;
传感器设备注册的第三步:完成相关初始化,上述代码中是这么实现的:
sensor_temp->info.type = RT_SENSOR_CLASS_TEMP;
sensor_temp->info.vendor = RT_SENSOR_VENDOR_DALLAS;
sensor_temp->info.model = "ds18b20";
sensor_temp->info.unit = RT_SENSOR_UNIT_DCELSIUS;
sensor_temp->info.intf_type = RT_SENSOR_INTF_ONEWIRE;
sensor_temp->info.range_max = SENSOR_TEMP_RANGE_MAX;
sensor_temp->info.range_min = SENSOR_TEMP_RANGE_MIN;
sensor_temp->info.period_min = 5;
rt_memcpy(&sensor_temp->config, cfg, sizeof(struct rt_sensor_config));
sensor_temp->ops = &sensor_ops;
传感器设备注册的三个步骤完成之后,就可以放心大胆地注册传感器设备了,上述代码中是这么实现的:
rt_hw_sensor_register(sensor_temp, name, RT_DEVICE_FLAG_RDONLY, RT_NULL);
上述的“ops 接口对接”和“传感器设备注册”两个工作完成后,就可以通过 Sensor 框架中的上层接口 open/close/read/write/control,对 ds18b20 进行操作了。
在 FinSH 中输入 list_device 命令查看 ds18b20 温度传感器是否真的已经被注册上去了。
在线程中读取温度数据
我们通过一个线程,去实时获取 ds18b20 的温度数据。
线程的基本操作有:创建/初始化 ( rt_thread_create/rt_thread_init)、启动 (rt_thread_startup)、运行 (rt_thread_delay/rt_thread_control)、删除/脱离 (rt_thread_delete/rt_thread_detach)。
之前将ds18b20对接到ops接口并成功注册成传感器设备了,接下来就是利用Sensor框架的上层接口open/read/write等对ds18b20斤操作了。
在main函数中创建一个读取ds18b20温度数据的线程并启动它,线程入口函数read_temp_entry:
rt_thread_t ds18b20_thread, led_thread;
ds18b20_thread = rt_thread_create("18b20tem",
read_temp_entry,
"temp_ds18b20",
512,
RT_THREAD_PRIORITY_MAX / 2,
20);
if (ds18b20_thread != RT_NULL)
{
rt_thread_startup(ds18b20_thread);
}
在线程入口函数read_temp_entry中,我们通过几个步骤,就可以读取ds18b20的温度数据了:
- 创建一个rt_sensor_data的数据结构体
- 查找传感器设备驱动
- 打开对应的传感器设备
- 读取传感器设备数据
static void read_temp_entry(void *parameter){
rt_device_t dev = RT_NULL;
struct rt_sensor_data sensor_data;
rt_size_t res;
dev = rt_device_find(parameter);
if(dev == RT_NULL){
rt_kprintf("Can't find device:%s\n", parameter);
return;
}
if (rt_device_open(dev, RT_DEVICE_FLAG_RDWR) != RT_EOK)
{
rt_kprintf("open device failed!\n");
return;
}
rt_device_control(dev, RT_SENSOR_CTRL_SET_ODR, (void *)100);
while (1)
{
res = rt_device_read(dev, 0, &sensor_data, 1);
if (res != 1)
{
rt_kprintf("read data failed!size is %d\n", res);
rt_device_close(dev);
return;
}
else
{
rt_kprintf("temp:%3d.%dC, timestamp:%5d\n", sensor_data.data.temp / 10, sensor_data.data.temp % 10, sensor_data.timestamp);
}
rt_thread_mdelay(100);
}
}
邮箱与消息队列
- 通过ENV工具获取NRF24L01软件包,并加载到MDK工程里面。
- 了解多线程间通信,了解IPC中邮箱和消息队列的特性,并灵活使用,实现ds18b20与nrf24l01线程之间的数据通信。
- 修改nrf24l01软件包,实现多点通信功能。
软件包的获取
软件包可以通过env工具十分方便获取到,并加载到工程里面去,env工具的下载链接可以在官网找到。
我们需要用到 nrf24l01 的软件包,只需要在 menuconfig 中选中 nrf24l01 即可:
选中之后需要将该软件包获取到本地来,在env中输入pkgs --update命令回车即可。
我们在工厂目录的packages目录下,可以看到nrf24l01软件包被获取到本地来了。
不过软件包现在仅仅只是获取到本地,尚未加载到MDK工程当中。我们在env中输入scons --target=mdk5命令回车后,执行完该命令之后打开 MDK5 工程,发现 nrf24l01 软件包成功加载到工程里面去了。
为什么要使用邮箱?
我们需要通过NRF24L01无线模块进行数据发送与接收。
在发送节点创建一个线程,用于无线发送数据。具体的,nrf24l01 的软件包提供了哪些 API,是如何通过这些 API 实现发送功能的,可以参考该软件包的 samples,路径为:…\packages\nrf24l01-latest\examples。
同理的,我们在 main 函数中再创建一个线程,该线程是用来通过 nrf24l01 发送数据的,线程入口函数是 nrf24l01_send_entry
问题变为:
- 温湿度传感器数据如何将温湿度数据给发送线程?
- 线程采集温度过快,发送线程来不及发送怎么办?
- 线程发送数据过快,温湿度线程来不及采集温度数据怎么办?
将内存池和邮箱一起配套使用。
邮箱工作原理
我们拟定一个生活场景。小区内放置有快递柜,快递柜里面有很多快递箱,快递箱里面可以存放快递,快递员把快递存放到快递箱之后,会发短信通知你过来取快递,还会告诉你编号是多少,通过编号你可以找到你的快递存放在快递柜的哪个快递箱里面。
上面这个模型中有几个名词,我们抽取出来:快递、快递柜、快递箱、快递员、你自己、短信、编号。
我们将上面这个生活场景和 IPC 中的邮箱和内存池一一对应起来:
- 快递:采集到的温度数据。
- 快递柜:内存池。
- 快递箱:内存池里面的内存块。
- 快递员:采集温湿度线程
- 自己:发送线程
- 短信:邮箱中的一封邮件
- 编号:内存块地址指针
邮箱和内存池的使用,其实和收快递是一样的:
在程序的一开始,即main函数中,我们创建一个邮箱和一个内存池。
- 在温湿度线程里,首先每当线程采集到一个温度数据,就在内存池里面申请一个内存块,把本次采集到的温度数据放到内存块里,再把内存块的地址放在邮箱里,最后把邮件发送出去。
- 在发送线程里:首先发送线程接收邮件,然后根据邮箱中存放的地址,知道了当前温度数据存放在哪个内存块里面,也就是说,发送线程找到当前温度数据。最后用完这个内存块要及时释放掉。
tmp_msg_mb = rt_mb_create("temp_mb0", MB_LEN, RT_IPC_FLAG_PRIO);
tmp_msg_mp = rt_mp_create("temp_mp0", MP_LEN, MP_BLOCK_SIZE);
static void read_temp_entry(void *parameter)
{
struct tmp_msg *msg;
rt_device_t dev = RT_NULL;
rt_size_t res;
dev = rt_device_find(parameter);
if (dev == RT_NULL)
{
rt_kprintf("Can't find device:%s\n", parameter);
return;
}
if (rt_device_open(dev, RT_DEVICE_FLAG_RDWR) != RT_EOK)
{
rt_kprintf("open device failed!\n");
return;
}
rt_device_control(dev, RT_SENSOR_CTRL_SET_ODR, (void *)100);
while (1)
{
res = rt_device_read(dev, 0, &sensor_data, 1);
if (res != 1)
{
rt_kprintf("read data failed!size is %d\n", res);
rt_device_close(dev);
return;
}
else
{
//申请一块内存,要是内存池满了,就挂起等待
msg = rt_mp_alloc(tmp_msg_mp, RT_WAITING_FOREVER);
msg->timestamp = sensor_data.timestamp;
msg->int_value = sensor_data.data.temp;
rt_mb_send(tmp_msg_mb, (rt_ubase_t)msg);
msg = NULL;
}
rt_thread_mdelay(100);
}
}
在上述代码中,该线程采集到一个温度数据之后,就会在内存池中申请内存块:
msg = rt_mp_alloc(tmp_msg_mp, RT_WAITING_FOREVER);
将温度数据存放到刚刚申请到的内存块里面:
msg->int_value = sensor_data.data.temp;
将这个存放着温度数据的内存块的地址给邮箱,然后发送邮件:
rt_mb_send(tmp_msg_mb, (rt_ubase_t)msg);
nrf24l01_thread 线程的入口函数是 nrf24l01_send_entry,如下:
static void nrf24l01_send_entry(void *parameter)
{
struct tmp_msg *msg;
struct hal_nrf24l01_port_cfg halcfg;
nrf24_cfg_t cfg;
uint8_t rbuf[32 + 1] = {0};
uint8_t tbuf[32] = {0};
nrf24_default_param(&cfg);
halcfg.ce_pin = NRF24L01_CE_PIN;
halcfg.spi_device_name = NRF24L01_SPI_DEVICE;
cfg.role = ROLE_PTX;
cfg.ud = &halcfg;
cfg.use_irq = 0;
nrf24_init(&cfg);
while (1)
{
rt_thread_mdelay(100);
if (rt_mb_recv(tmp_msg_mb, (rt_ubase_t*)&msg, RT_WAITING_FOREVER) == RT_EOK)
{
if (msg->int_value >= 0)
{
rt_sprintf((char *)tbuf, "temp:+%3d.%dC, ts:%d",
msg->int_value / 10, msg->int_value % 10, msg->timestamp);
}
else
{
rt_sprintf((char *)tbuf, "temp:-%2d.%dC, ts:%d",
msg->int_value / 10, msg->int_value % 10, msg->timestamp);
}
rt_kputs((char *)tbuf);
rt_kputs("\n");
rt_mp_free(msg); /* 释放内存块 */
msg = RT_NULL; /* 请务必要做 */
}
if (nrf24_ptx_run(rbuf, tbuf, rt_strlen((char *)tbuf)) < 0)
{
rt_kputs("Send failed! >>> ");
}
}
}
该线程接收ds18b20_thread 线程发送过来的邮件,并收到了温度数据:
rt_mb_recv(tmp_msg_mb, (rt_ubase_t*)&msg, RT_WAITING_FOREVER)
将温度数据发送出去:
nrf24_ptx_run(rbuf, tbuf, rt_strlen((char *)tbuf))
用完的内存块释放掉
rt_mp_free(msg);
msg = RT_NULL;
还有两个问题没有解答:
如果ds18b20_thread 线程采集温度数据过快,nrf24l01_thread 线程来不及发送,怎么办?
如果nrf24l01_thread 线程发送数据过快,ds18b20_thread 线程来不及采集温度数据,怎么办?
这两个问题其实就是解决供过于求和供不应求的问题。
申请内存块、接收邮件的代码上有RT_WAITTING_FOREVER
内存池满了的时候,再也申请不到内存块了,这时候申请内存块的线程就会阻塞,并挂起,然后MCU就去做别的事情了。
发送线程不断发送内存池中的温度数据,并释放掉内存块。等一有内存块可以申请了,发送线程被唤醒,又会往里面塞数据了。
同理的,如果内存池是空的,里面没有数据,接收邮件里面的 RT_WAITING_FOREVER 会使得nrf24l01_thread 线程阻塞,并挂起,然后 MCU 就会去干别的事情去了,在 ds18b20_thread 线程中采集温度,并申请内存块塞数据进去,内存块一旦有数据,就会发邮箱,另外一边一有邮箱收到了,就又开始工作了。
消息队列
消息队列一般来说,不需要搭配内存池一起使用,因为消息队列创建的时候会申请一段固定大小的内存出来,作用其实和邮箱+内存池是一样的。
每个消息队列对象包含着多个消息框,每个消息框可以存放一条消息,每个消息框的大小是一样的,存放的消息大小不能超过消息框的大小,即可以相同,可以小于。
tmp_msg_mq = rt_mq_create("temp_mq", MQ_BLOCK_SIZE, MQ_LEN, RT_IPC_FLAG_PRIO);
在传感器线程中转载数据并发送消息队列:
msg.int_value = sensor_data.data.temp;
rt_mq_send(tmp_msg_mq, &msg, sizeof msg);
在线程中接收消息队列
rt_mq_recv(tmp_msg_mq, &msg, sizeof msg, RT_WAITING_FOREVER);
文件系统
DFS是RTT提供的虚拟文件系统组件,全称为Device File System,即设备虚拟文件系统,文件系统的名称使用类似UNIX文件、文件夹的风格。
功能:
- 为应用程序提供统一的POSIX文件和目录操作接口:read、write、poll、select等。
- 支持多种类型的文件系统,如FatFS、RomFS、DevFS等,并提供普通文件、设备文件、网络文件描述符的管理。
- 支持多种类型的存储设备,如SD CARD、SPI Flash等。
在SPI Flash上使用文件系统
RTT已经将libc那套文件系统接口对接到DFS上了,在env工具中开启libc和DFS即可,本次教程使用libc的那套接口进行文件的打开/关闭、读取/写入。
OneNet连云
首先,要想清楚本次任务的整个工程需要依赖什么工具才能正常工作,简单来说,我们需要将 ESP8266 对接到 OneNet 云,而 ESP8266 通过 AT Device 控制的,所以现在目标是明确的:开启 AT 和 ESP8266,并配置 OneNet 软件包中的相关参数。
开启 AT 和 ESP8266,ESP8266 的 WIFI 账号和密码需要写对,不然连不上网,自然就对接不上 OneNet 了:
OneNet软件包工作原理
OneNet软件包数据的上传和命令的接收是基于MQTT实现的,OnetNet的初始化其实就是MQTT客户端的初始化,初始化完成后,MQTT客户端会自动连接OneNet平台。
数据的上传其实就是往特定的topic发布消息。当服务器有命令或者响应需要下发时,会将消息推送给设备。
获取数据流、数据点,发布命令则是基于HTTP Client实现的,通过POST或GET将相应的请求发送给OneNet平台,OneNet 将对应的数据返回,这样,我们 就能在网页上或者手机 APP 上看到设备上传的数据了。
OneNet软件包提供了一个接口onenet_mqtt_init,供用户去初始化MQTT,只有当MQTT初始化成功之后,才能后续的操作,如上传数据到OneNet服务器。
我们创建两个线程去工作,onenet_mqtt_init_thread线程用于初始化 MQTT 客户端,onenet_upload_data_thread 线程去上传数据给云。那么问题来了,onenet_upload_data_thread 线程怎么知道 MQTT 初始化成功了呢?这里使用信号量去通知。
要用信号量之前,第一步当然是创建一个信号量:
mqttinit_sem = rt_sem_create("mqtt_sem", RT_NULL, RT_IPC_FLAG_PRIO);
初始化MQTT成功后,释放信号量。
static void onenet_mqtt_init_entry(void *parameter)
{
uint8_t onenet_mqtt_init_failed_times;
/* mqtt初始化 */
while (1)
{
if (!onenet_mqtt_init())
{
/* mqtt初始化成功之后,释放信号量告知onenet_upload_data_thread线程可以上传数据了 */
rt_sem_release(mqttinit_sem);
return;
}
rt_thread_mdelay(100);
LOG_E("onenet mqtt init failed %d times", onenet_mqtt_init_failed_times++);
}
}
上传线程要一直等待信号量的到来。
static void onenet_upload_data_entry(void *parameter)
{
/* 永久等待方式接收信号量,若收不到,该线程会一直挂起 */
rt_sem_take(mqttinit_sem, RT_WAITING_FOREVER);
/* 后面用不到这个信号量了,把它删除了,回收资源 */
rt_sem_delete(mqttinit_sem);
while (1)
{
/* 这里是上传数据到OneNet的代码*/
/* 这里要怎么写,后面会有教程说明 */
}
}
static void onenet_upload_data_entry(void *parameter)
{
struct recvdata *buf_mp;
/* 永久等待方式接收信号量,若收不到,该线程会一直挂起 */
rt_sem_take(mqttinit_sem, RT_WAITING_FOREVER);
/* 后面用不到这个信号量了,把它删除了,回收资源 */
rt_sem_delete(mqttinit_sem);
while (1)
{
if (rt_mb_recv(tmp_msg_mb, (rt_ubase_t*)&buf_mp, RT_WAITING_FOREVER) == RT_EOK)
{
/* 500ms上传一次数据 */
rt_thread_delay(rt_tick_from_millisecond(500));
/* 上传发送节点1的数据到OneNet服务器,数据流名字是temperature_p0 */
if (onenet_mqtt_upload_digit("temperature_p0", buf_mp->temperature_p0) != RT_EOK)
rt_kprintf("upload temperature_p0 has an error, try again\n");
else
printf("onenet upload OK >>> temp_p0:%f\n", buf_mp->temperature_p0);
rt_mp_free(buf_mp); /* 释放内存块 */
buf_mp = RT_NULL; /* 请务必要做 */
}
}
}
如果说,我们底下有多个发送节点采集温度,接收节点会收到多个发送节点的温度数据,而只有 一个 ESP8266,怎么在上传数据给 OneNet 的时候区分这些不同节点的数据?这里其实只需要建立不同的数据流就好了,每一个节点的数据为一个数据流,rt_err_t onenet_mqtt_upload_digit(const char *ds_name, const double digit) 函数的第一个参数就是数据流的名称,起不同名字就是不用的数据流了,如:
/* 上传发送节点1的数据到OneNet服务器,数据流名字是temperature_p0 */
onenet_mqtt_upload_digit("temperature_p0", buf_mp->temperature_p0);
/* 上传发送节点2的数据到OneNet服务器,数据流名字是temperature_p1 */
onenet_mqtt_upload_digit("temperature_p1", buf_mp->temperature_p1);