目录
一、想法及需求
1.1最初设想
1.2需求分析
1.3方案设计
二、硬件
2.1器材选型
2.2原理图解释
2.3PCB绘制
2.4焊接及成品
三、软件
3.1NETTY自定义协议的TCP服务器
3.1.1使用原因
3.1.2制定协议
3.1.3编写解码器
3.1.4编写服务器
3.1.5部署服务器(将netty服务器部署到阿里云服务器)
3.1.6 netty连接数据库
3.2嵌入式代码
3.3基于Thymeleaf的前端展示
题源于物联网综合设计课设,要求做一套简易的物联网综合系统。由于宿舍楼下就是操场,很经常举办一些类似运动会、草地弹唱会的活动,影响到同学们的休息,所以想做一套校园噪声监测系统,这篇文章记录了制作过程中的心路历程。
首先来看看最后的效果,大屏展示出各个节点的实时状态以及位置,当出现噪声时进行报警并发送短信到工作人员处,由于短信需要费用,这里使用邮件代替:
校园噪声检测系统的用户是保卫科管理人员,对象的需求大致为以下三点:
①及时提示有噪声出现并且显示噪声出现的地点
②及时通知工作人员,保证消息传送的及时性
③记录噪声出现的地点、时间以及大小,归纳噪声规律,从而更好地解决噪声问题
在校园的各个容易产生噪声的地方安装传感器节点,但节点之间不进行互相通信(不构成WSN无线传感网)。每个节点由四个单元组成,分别是感知单元、处理单元、通信单元和能量供给单元,最后所有节点的数据将在大屏上进行展示(如下图所示)。
对于数据的传输,分为以下几个部分:传感器节点发送TCP数据包到使用NETTY搭建的数据服务器,数据服务器解析数据包为一个个数据对象,使用JDBC存储到数据库中,而数据服务器一样可以通过JDBC来获取数据库中的数据,以进行数据的处理及展示。
本篇文章将分为以下几个部分进行讲解:硬件、软件以及成功展示。
首先是关于器材的选型,在第一部分已经说过,每个传感器节点由四个单元组成,分别是感知单元、处理单元、通信单元和能量供给单元。感知单元最后选型为LM386声音传感器,它实际上是一个AD转化器,将噪声模拟量转化为电压的大小;处理单元以及能量供给单元最后选用STM32F103C8T6的最小系统板,不论从IO输出,传输效率,处理效率上,STM32相对于C51而言都占了较大的优势;通信模块选用了ESP8266进行wifi传输,由于节点之间距离太远,并不进行相互通信,而是各自通过wifi连上互联网,发送TCP数据包到数据服务器。各个模块的图片如下:
STM32F103C8T6最小系统板:
ESP8266:
USB转串口模块CP2102:
LM386声音传感器模块:
OLED显示屏模块:
按从左到右,从上至下的顺序进行说明:
首先是wifi模块,这里有一个需要注意的地方,wifi模块必须要与串口相连,通过串口来进行收发数据。对于SMT32F103C8T6而言,它的串口1为PA9和PA10,而串口2为PA2和PA3,这里注意ESP8266同串口1连接好像容易出问题,所以选择串口2,接着就是wifi模块的RXD要连在STM32的TXD上,TXD连在RXD上,这是由于它的工作机制:STM32发送AT指令到wifi模块处,wifi模块识别指令并进行数据的转发,通过互联网传到数据服务器处。
接着是声音传感器模块,本质上是一个AD转化起,将模拟量噪声转化为电压的大小进行读取,这里注意,这个传感器并没有给出分贝与电压之间的转化公式,需要自己用声级计进行测试,这里不推荐使用这个传感器,最好去找一些精确度较高的传感器模块。
接着是LED模块,当测量噪声达到阈值时,LED发光进行报警。
USB转串口模块方便于同电脑间进行信息的传输以获取芯片实时的状态,方便调试。
OLED模块使用IIC接口,将7针的SPI接口缩小到了4针,合理利用了IO口的资源。
BUTTON模块并没有用到,可以进行复位或者调解参数使用,但是注意:这里的BUTTON接法有误,由于没有用到就没有进行修改,正确的接法如下:
STM32最小系统板:注意绘制封装时将每一个引脚对应上,以及每一个模块的尺寸都要画正确。
根据上图画好电路图后,转为PCB并进行布局、布线以及铺铜,布局及布线要进行合理规划,如果STM32的引脚的接入端被大致均匀的分到了左右两边,一般建议将STM32放在中间,就可以将连线错开,减少过孔的数量。在板的四角还可以进行开孔,使用M3六角铜柱进行支撑。
PCB的绘制只需最简单的对排母的绘制,这里可以进行一些改进:比如OLED屏是很小的排母支撑起较大的屏幕,重心不稳且容易断,可以在它的附近再焊上一个排母,起支撑作用。CP2102由于没有用到就没有进行焊接。
为什么要使用自定义的协议呢,原因有三:
①常规的物联网系统是连接到大公司搭好的平台上,如使用mqtt连接到中国移动的onenet平台,但缺点就是公司可以掌握你的所有数据,以及有一种受制于人的感觉。
②自定义协议方便扩展其余节点,只需要所有节点统一协议即可。
③想学一些新东西,比如这里用到的netty。
由于本项目只需要传输声音值的大小,传输的内容简单,也不需要定义非常复杂的协议,协议结构如下:
魔数 | 传感器节点序号 | 数据部分的长度 | 数据 |
其中魔数用来第一时间判断出是否为无效包,是收发双方提前约定好的数据,这里我定义为‘0717’四个字节;由于传感器得到的数据时确定三位的,所以可以确定数据部分的长度,节点每次发送时只需修改对应的数据部分以及节点序号即可。以下是iot_protocol 协议类:
package com.zhiqi.IOT.protocol;
import java.util.Arrays;
/***
* 自定义的协议如下
* | ---- | -------------- | -------------- | ---- |
* | 魔数 | 传感器节点序号 | 数据部分的长度 | 数据 |
* | ---- | -------------- | -------------- | ---- |
*/
public class iot_protocol {
/**
* 魔数 4个字节
*/
private int head_data ;
/**
* 传感器节点序号 1个字节
*/
private int id ;
/**
* 数据的长度 3个字节
*/
private int contentLength;
/**
* 数据的内容
*/
private String content;
@Override
public String toString() {
return "iot_protocol{" +
"head_data=" + head_data +
", id=" + id +
", contentLength=" + contentLength +
", content='" + content + '\'' +
'}';
}
public int getHead_data() {
return head_data;
}
public void setHead_data(int head_data) {
this.head_data = head_data;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public int getContentLength() {
return contentLength;
}
public void setContentLength(int contentLength) {
this.contentLength = contentLength;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public iot_protocol() {
}
public iot_protocol(int head_data, int id, int contentLength, String content) {
this.head_data = head_data;
this.id = id;
this.contentLength = contentLength;
this.content = content;
}
}
对于解码器而言,判断正误,依次读出每一个数据即可,但是有几个值得关注的点:
①对于魔数0717,解码器使用buffer.readInt()方法是一次读取四个字节的(一个int占四个字节),所以这里的0717会被转化成16进制的ascii码:30 37 31 37,合起来看做一个数,使用计算机计算结果如下,为808923447:
②对于节点编号只有一个字节,发送端发送后转化为ascii码,在接收端解码时使用字节读出,读出的是十进制ascii码,将这个数字减去48后就是正确的编号。
③接下来是3位的数据长度,这里我选择使用三次readByte()读出三位,读出的三位分别减去48后,换算成整体的十进制。
④得到数据长度后,可以就可以创建一个数据长度大小的byte数组,使用buffer.getbytes方法直接获取所有数据,再使用String的构造函数转化为String。
解码器代码如下:
package com.zhiqi.IOT.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.util.List;
//自定义协议的解码器 根据协议的格式进行对发来的数据包的解码
public class iot_protocol_decoder extends ByteToMessageDecoder {
/***
* 魔数 4个字节
* 传感器节点序号 1个字节
* 数据长度 3个字节
* 最基本的长度为10个字节 如果数据包小于10则发生异常
*/
public final int BASE_LENGTH = 4 + 1 + 3;
//重写解码方法
protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf buffer, List
遇到的几个注意点:
①在Netty开发当中,需要自定义ChannelInboundHandlerAdapter,在重写channelRead时会传入msg对象,此对象在使用完毕后必须释放,否则会导致对象池泄露内存溢出(报错Discarded inbound message {} that reached at the tail of the pipeline.Please check your pipeline configuration.)。可以直接使用SimpleChannelInboundHandler,会自动释放对象。
②可以使用sockettool来测试服务器,这里来演示一下:比如说之前说的魔数0717会被识别成一个新的int,我们连接上localhost:8266,进行测试:
对整体进行测试,观察解码器是否正确。
首先对maven项目进行打包:
使用winscp将jar包上传至云端:
打开putty运行此jar包(记得将目录切换到jar包的位置):
运行后打开日志发现报错,是因为没有指定主类,回到idea中来指定(修改pom文件):
org.apache.maven.plugins
maven-jar-plugin
com.zhiqi.IOT.Server.server
继续报错:
没想到大半夜的在这个地方卡了好久,netty导出jar包不知道为什么不能用maven的package,而要用idea自带的build,先在project structure中设置:
接着继续设置:
再次运行,服务器启动:
接着进行测试:
这里又踩坑了,可以和这个端口建立连接但是一直接收不到数据,经过了好长时间的调试,修改了阿里云安全组、给netty绑定上公网ip等等,最后发现代码根本没错,是由于解码器中设置了发过来的数据的格式,而我一开始没有按这个格式发送而导致接收不到,被当做无用包舍弃了,查阅资料后有如下解释:
首先,对于一个程序来讲,它所绑定的IP只能是其所在机器(无论物理机还是虚拟机)上的某个网卡的IP地址,这个你可以到机器上运行ifconfig
查看。
其次,所谓绑定的含义是规定程序能够监听到哪个目的地IP的IP包,比如机器有两个网卡A和B,IP地址分别是AIP和BIP,你的程序绑定AIP,那么操作系统只会将目的地是AIP的IP包转发给你的程序。0.0.0.0
是特殊的,它代表着能够转发目的地IP是机器上任意IP的IP包到你的程序。
最后,为何可以通过公网IP访问到你的机器?这是因为云服务商给你做了NAT,而这个地址你的机器是不知道的,也不属于你的机器上的任意一张网卡,所以你无法绑定。
按规定格式发送数据后可以通信:
数据库表格如下:
①position表:
id是每个节点的编号,lat纬度,lng经度,address是文字版的详细地址,ismail记录发生噪声干扰时是否已经发送邮件给管理员。
②now_data表
记录每个节点的当前数据,即网页地图实时展示的数据,当每次接收到传感器节点传来的数据时,就会进行now_data数据的更新,并将之前的数据记录到history_data表中。
③history_data表
此表结构与now_data相同。
注意的几个点:
①super传播
super.channelRead(ctx, msg);
在每一个Handler中都应该让数据传输到下一个ChannelInboundHandler中。如果没有其他处理程序在意msg,则无需调用它。
②业务逻辑
在netty的业务逻辑中,将IO操作和占用时间的数据库操作分开,以免造成IO进程的堵塞。IO操作是放在NioEventLoopGroup中的,而数据库等业务是放在DefaultEventLoopGroup中的。代码如下:
首先在server类中放上DefaultEventLoopGroup,要在一个私有类中进行使用。
//配置业务线程组
final EventLoopGroup businessGroup=new DefaultEventLoopGroup(16);
接着在pipeline中添加businessGroup:
/**
* 网络事件处理器
*/
private class ChildChannelHandler extends ChannelInitializer {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 添加自定义协议的解码工具 处理完会将一个iot_protocol类型的数据传给ServerHandler
ch.pipeline().addLast(new iot_protocol_decoder());
// 处理网络IO
ch.pipeline().addLast(new ServerHandler());
//处理数据库业务逻辑
ch.pipeline().addLast(bussinessGroup,new bussinessHandler());
}
}
然后定义绑定上的businessHandler类,重写方法进行对数据库的操作,记得要对错误进行捕获处理,避免服务器宕机:
private class bussinessHandler extends ChannelInboundHandlerAdapter{
/**
* 在这里写入对数据库的操作
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
iot_protocol sound =(iot_protocol)msg;
Data data=new Data();
statement =connection.createStatement();
//把原先的数据取出来
ResultSet resultSet =statement.executeQuery("select * from now_data where id=" + sound.getId());
if(resultSet.next()){
data.setId((int)resultSet.getObject(1));
data.setSound((int)resultSet.getObject(2));
data.setTime(((Timestamp) resultSet.getObject(3)).toString());
}
if(data.getTime()!=null) {
//有数据才进行这些步骤 没有数据就直接插入
//插入到历史记录中
String sql = "insert into history_data values(" + data.getId() + "," + data.getSound() + ",'" + data.getTime() + "')";
System.out.println(sql);
statement.execute(sql);
//删除原有数据
statement.execute("delete from now_data where id=" + data.getId());
}
//更新新数据
statement.execute("insert into now_data(id,sound) values("+sound.getId()+","+Integer.parseInt(sound.getContent().substring(sound.getContent().length()-3))+")");
super.channelRead(ctx, msg);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
super.exceptionCaught(ctx, cause);
Channel channel = ctx.channel();
if (channel.isActive()) ctx.close();
}
}
③timestamp的插入
注意timestamp插入数据库中的格式,需要引号。
④部署服务器报错
ClassLoader classLoader = JdbcUtils.class.getClassLoader();
URL resource = classLoader.getResource("db.properties");
System.out.println(resource);
String path = resource.getPath();
//2.加载文件
try {
pro.load(new FileReader(path));
} catch (IOException e) {
e.printStackTrace();
}
原因是读取配置文件出错,在windows下可以,但是在linux下,url.getPath()得到的是properties文件的绝对路径,所以出错,改用以下方法:
ResourceBundle rb = ResourceBundle.getBundle("db");
url = rb.getString("url");
username = rb.getString("username");
password = rb.getString("password");
driver = rb.getString("driver");
到此netty服务器已搭建完成。
写累了,就简单写写吧...
嵌入式的代码时一步步慢慢完善的,从一开始点一个灯来确定PCB是否可用,到oled的显示,到传感器数据的读取及显示,再到使用esp8266对其联网上传。
点灯的程序过于简单就不说了,从oled开始说,导入中景园提供的oled代码后,首先修改oled.h头文件中对引脚的宏定义:先查看绘制的电路图
可以看到SCL接的是PB6,SDA接的是PB7,修改宏定义:
//-----------------OLED端口定义----------------
#define OLED_SCL_Clr() GPIO_ResetBits(GPIOB,GPIO_Pin_6)//SCL
#define OLED_SCL_Set() GPIO_SetBits(GPIOB,GPIO_Pin_6)
#define OLED_SDA_Clr() GPIO_ResetBits(GPIOB,GPIO_Pin_7)//DIN
#define OLED_SDA_Set() GPIO_SetBits(GPIOB,GPIO_Pin_7)
然后注意还有oled.c中的init函数有对GPIO的初始化,也需要进行修改:
void OLED_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); //使能B端口时钟
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6|GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; //推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;//速度50MHz
GPIO_Init(GPIOB, &GPIO_InitStructure); //初始化PB6,7
GPIO_SetBits(GPIOB,GPIO_Pin_6|GPIO_Pin_7);
}
接着是传感器数据的读取,使用了原子哥的adc代码,同样要修改GPIO引脚,这次在h头文件中没有宏定义,直接修改c文件的init函数:
void Adc_Init(void)
{
ADC_InitTypeDef ADC_InitStructure;
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA |RCC_APB2Periph_ADC1 , ENABLE ); //使能ADC1通道时钟
RCC_ADCCLKConfig(RCC_PCLK2_Div6); //设置ADC分频因子6 72M/6=12,ADC最大时间不能超过14M
//PA4 作为模拟通道输入引脚
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4|GPIO_Pin_6;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; //模拟输入引脚
GPIO_Init(GPIOA, &GPIO_InitStructure);
ADC_DeInit(ADC1); //复位ADC1,将外设 ADC1 的全部寄存器重设为缺省值
...
}
同时,由于使用的是PA4引脚,查询数据手册可知是通道4,所以在调用读取函数时需要选择为channel4。
adcx=Get_Adc_Average1(ADC_Channel_4,30);
最后是esp8266的代码
一开始使用onenet官方给的代码,遇到了wifi接不上的问题,查询后发现是命令错误,官方给的是AT+CWJAP=,而实际上要是AT+CWJAP_DEF=。
更改后就可以连接上wifi,传输数据到netty成功:
输出重定向:不是printf而是sprintf,需要引入头文件#include "stdio.h".
注:这里%3d也有错误,前面不会补0,使用%03d才可以
在使用wifi模块遇到了很严重的问题,在定时器中断内无法发送数据,原先设想在定时器中断时读取并显示数据,再上传至数据服务器,但是在中断内不知道为什么发送到服务器端的却是AT指令,尝试了好久(设置中断优先级、查阅AT指令原理)也没有找到解决方法,最后只能在main函数中使用while和delay对数据进行发送,这也就意味着每次测量的显示值和发送到云端的值不同。
原本想用VUE来进行前端的搭建,框架选用ElementUI,一个界面已经搭建好了,但是发现如果想要部署到服务器上,需要使用Nginx。但是!!现在是凌晨1点,距离答辩还有七小时,只能找到以前做的一个项目进行修改,调用了腾讯地图api,在前端设置了定时器进行Ajax轮询来定时获取数据库。