目录

一、代码规范

二、基本框架

2.1 目录结构

2.2 公共配置

三、通讯协议

3.1 基础协议

3.2 命令字

3.3 加密方式

3.4 密码类型

四、广播搜网

4.1 广播逻辑

4.2 核心参数

4.3 运行模式

4.4 时间同步

4.5 天线

4.6 节点搜网

五、数据上行

5.1 发送任务

5.2 静态发送

5.3 动态发送

5.4 下行尾随

六、数据下行

6.1 网关下行管理

6.2 从机发送

6.3 节点接收

七、设备间发送(D2D)

八、OLED屏幕

8.1 OLED驱动

8.2 OLED应用

九、应用配置

9.1 节点配置

9.2 网关配置

9.3 从机配置

十、节点运行逻辑

10.1 任务模块

10.2 闹钟计算

10.3 事件通知

十一、总结


一、代码规范

        上图的工程代码部分截图,用Keil作为开发工具,根据截图框起来部分不难看出项目的代码风格,这里做些总结:

        1.函数名、文件名用蛇形命名法,即小写字母加下划线“_”;

        2.结构体、枚举变量用帕斯卡命名法,即大小写穿插;

        3.驱动文件统一用“drv_”开头,例如drv_sx1278.c、drv_encrypt.c等;

        4.应用层文件统一用“app_”开头,例如app_node.c、app_oled96.c;

        5.协议栈相关内容都是"Nwk"或“nwk”开头,即network网络的缩写;

        6.协议栈相关内容“nwk”后紧跟角色名称,这里的角色名称有三种,分别是“node”节点,“master”网关主机和“slave”网关天线从机;

        7.全局结构体变量名称用“g_s”开头,g是global全局的意思,s是Struct的意思,例如NwkNodeWorkStruct g_sNwkNodeWork={0};

        8.局部指针引用以*p开头+大写字母,例如

NwkNodeSearchStruct *pSearch=&g_sNwkNodeWork.node_search;

        9.函数命名尽量用 有效关键字,函数名尽量短、参数尽量少,要让人一眼就知道函数的大概作用,例如 void nwk_node_cad_init(void) 一看就知道是节点的CAD初始化函数;

        10.尽量用unsigned char,即u8,不要用char,因为有些编译器char范围是-127~128,有些编译器范围是0~255,如果对数值正负敏感的话要注意处理;

        11.对数值范围敏感的话也不要用int,int在不同的单片机宽度不一样,最好根据芯片厂家的代码库来定义,

        12.宏定义一般都用大写+下划线的方式,同时如果允许用户进行配置的话,要用#ifndef的形式,这样用户就不用在公用文件上改来改去,影响其他项目了;

        13.结构体要注意四字节对齐,这样可以节省空间,特别是需要创建结构体数组的情况,不对齐会浪费很多空间。

        代码命名就像人的形象气质一样,好的规范自然就是帅哥美女了,让人自然而然就想去阅读。写代码写多了后会发现,命名也是个让人很头疼的事情,特别是项目时间紧迫的情况下,没有过多时间考虑,随便命名,甚至用拼音,等项目结束了、回头看跟屎一样。项目里有用了之前的一些旧代码,没有按照规范来,望见谅。

二、基本框架
2.1 目录结构

        LoRaSun协议栈可以类比于mqtt开源库,它的作用就是组合、分解数据包,使得数据按照特定的方式进行收发,而用户无需多多关心内在的过程,拿来即用。只不过MQTT针对的物理层是以太网、4G、WiFi等类似网络,LoRaSun针对的是LoRa无线网。

        整个协议栈角色有三个,分别是前文提到的终端节点、网关主机、网关天线从机,对应的就有三个Keil工程了,为了便于管理,统一使用STM32F103C8T6作为主控芯片。

2.2 公共配置

        项目中,有一个三者共用的基础文件nwk_bsp.c,里面包含了协议栈的一些基本定义,比如频率范围、广播参数、协议版本等,还包含了crc16、延时、加密等基本函数;其中有个很重要的是通道表,即SF和BW的组合,一个网络要互通,这个通道表是要保证一致的,在这里为了兼容LLCC68,我选用了最大的SF是11,当然,这个通道表是可以自定义的,要自己权衡速度跟距离之间的关系,一般也不建议搞太多通道,这样网关天线的整体CAD检测效率会降低,降低监听成功率。

三、通讯协议
3.1 基础协议

        下图是数据包组合函数,里面包含的就是基本的通讯协议:

        1.首先是A5开头,相当于帧头,用nwk_bsp文件里的公用函数nwk_find_head查找匹配。

        2.后面跟着选项字节opt,opt的每个bit定义在框起来部分中间行,包含了网络角色、加密方式、密码类型以及扩展位,以备不时之需。

        3.接下来是发送发的SN、负载长度和负载,其中负载是加密的,前面部分是明文,接收方根据明文部分的信息选择合适的密码和算法进行解密。

        4.解密出来后的数据包含了数据长度、目标SN、命令字、包序号和校验码,接收方进行CRC校验,确保数据的正确性,然后对目标SN、命令字和包序号进行二次匹配,以保证数据的正确用途。

        5.网络角色分为两种,节点和网关,这样接收方就知道数据是哪一方发送来的,需要采取相应的策略,因为协议栈是支持设备间(D2D)发送的,所以这里要进行区分。

3.2 命令字

        命令字是很重要的一个字段,他告诉了接收方这个数据包的作用,以下是协议栈支持的命令字,并不复杂:

        1. 心跳命令属于保留功能,暂时没什么用;

        2.回复命令用于协议栈交互结束前的一个确认信号,以保证接收方确实收到数据了;

        3. 入网命令这个很容易理解了,由节点主动发起的、申请加入网关,网关会回复入网结果,当前默认都是允许入网的,方便测试,后续还要加入一些更完善的机制,比如网关未配置的节点SN不允许入网、网关管理容量满了不允许入网等等。

        4.单包数据命令是核心指令了,用于数据上下行,单包数据长度由nwk_bsp.h中的宏定义NWK_TRANSMIT_MAX_SIZE决定,默认200字节,一般都够用了。

        5.连续数据命令用于发送大数据包,其实协议层的交互本质上是一种握手机制,一般应用握手后只发送一次数据就可以了;在一些特殊场合,例如旧表计远程抄表,可能需要发送一些低分辨率图片,如果在握手后进行一次性发送,那么效率会增加,相应的信道也会被占用。这个功能暂未实现。

3.3 加密方式

        协议栈支持明文、TEA加密和AES三种加密模式,明文为了方便学习调试,查看发送内容;TEA加密单元是8字节,数据没对齐的时候冗余数据比较小,同时加密算法比较简单高效,占用空间小;AES加密采用AES-CBC模式,加密单元16字节,安全性高,对应的空间开销也更大,ROM:5KB左右。

3.4 密码类型

        密码类型分为根密码和应用密码,根密码属于初始密码,系统入网时使用,因为这时候只有根密码;入网时,网关返回的数据包内包含一个随机数组,节点会根据事先约定好的算法对这个数组进行运算,形成应用密码,随后节点与网关通讯都会使用这个应用密码,增加安全性。

        应用密码的主动性在于节点,为了进一步增加安全性,节点可以定时入网更新密码,比如每天更新。

        至于约定的算法,也可以自定义,这里简单的就用TEA算法和指定密码加密一下。

四、广播搜网
4.1 广播逻辑

        有了基本的通讯协议后,就可以进行协议层间的交互通讯,整体的交互流程是网关广播--节点搜网--入网--数据收发;所以,第一步就是广播部分的代码。

        

        两个关键内容是广播偏移和广播周期,广播偏移之前讲过了,是为了避免同一区域内的网关干扰,让网关按顺序广播;广播周期暂定为2分钟,这是一个比较合适的周期。

4.2 核心参数

        下图是具体的广播内容,框起来部分是网关的核心信息,包含起始频段、运行模式、广播天线ID和天线数量;起始频段可以保证在区域内的网关信道不会冲突,最大化利用国内的免费频段。

4.3 运行模式

        运行模式分为静态和动态模式,类似于路由器的动态IP和静态IP,动态模式就是用上一篇文章所述的CAD嗅探原理,单根天线就能满足自适应速率的需求,对应的弊端是CAD会导致距离缩水。静态模式本质上就是有自组网能力的点对点通讯,距离跟点对点一样,需要多跟天线配合才能实现自适应速率的需求。在部署时,可以使用 “静态为主、动态为辅” 的策略,在几个中心点部署多天线的静态模式网关,边缘盲区部署单天线的动态网关,实现低成本、快速地无盲区覆盖。

4.4 时间同步

        时间同步是件麻烦事,即便是秒级同步。如果直接用LoRa把当前时间直接广播出去会有两个问题,一个是LoRa发送时间比较长,按照默认参数SF11/BW6 需要1.3秒左右,这个误差就很大了;另一个是读取当前时刻的秒时间戳不一定是对齐的,可能是0.5秒,如果这个不消除,那么就有初始的0.5秒偏差了,留给晶振的容差空间就更小了。

        为了解决这个问题,首先要尽量保证发送前读取时间戳的时刻要秒对齐,这个比较好操作,就是增加读取频率,一旦时间戳满足周期的倍数(比如默认的120),即可认为是对齐的,该任务大概是10ms运行一次,所以初始对齐误差也是在10ms以内,完全可以接受。

        第二步是如何消除发送时间偏差,这里就有点小技巧了,我选用延时策略来保证发送秒对齐,具体看下图指示部分。首先计算空中时间,这个是比较准确的,误差就几十ms;然后判断这个空中时间是否为整秒数,一般都不是的,目前大概是1.3秒(16字节数据);接下来就是算延时的时间了,先求空中时间的余数(这里>50意思是多余50ms以上才需要对齐调整),1000减去这个余数就是要延时的毫秒数了;最后就是强制延时,硬凑对齐。那么,最终广播出去的时间就把发送时间和这个对齐的时间差补上就行了,下图箭头所示。这样同步的时间误差可以控制在100ms以内,再详细测试应该还能进一步精确。

4.5 天线

        广播选用的天线 理论上是谁有空就用谁,目前简单处理都是用第一根天线;在这种多天线网关系统里,天线的数量决定了网关的容量,是一个很重要的参数,这样节点才能综合评估上行数据的策略——大家尽量往信号好的、天线多的网关发送。至于节点处的策略目前还不完善,完全是随机化选择的。

4.6 节点搜网

        节点搜网触发条件主要有以下三个:一是刚开机,二是时间未同步,三是搜网周期到达。其中搜网周期可以应用层配置,包含周期和搜网时间,目前为了测试效率这两个参数都填得很小,周期是120秒,搜网时间是10秒;理论上设置为至少每天搜网2分钟比较合适,这样可以定时更新网关信息,当然,这个根据具体应用来确定。

搜网任务

搜网参数配置

五、数据上行
5.1 发送任务

        数据上行是指节点发送数据到网关,即下图所示函数,他作为一个模块按需运行,有发送任务了才会运行。

        发送接口由下面函数提供,给应用层调用。

5.2 静态发送

        静态发送较为简单,就是选择合适的网关、合适的天线,直接先发送一个抢占包,这个抢占包格式固定、内含SN信息、数据比较短,目的是先占用目标天线;如果该天线回应了,说明抢占成功,如果等待回应超时,那就是抢占失败,随机延时再度抢占。

        网关天线回应后就可以发送应用数据了,收到确认信号后就说明发送成功了,通知应用层发送成功,结束回合,整体逻辑比较简洁清晰。下面贴一些相关代码,具体看工程比较好理解。        

        if(pGateWay->run_mode==NwkRunModeStatic)//静态模式
        {
          pNodeTxGw->wireless_ptr=0;
          if(pGateWay->wireless_num>0)
          {
            pNodeTxGw->wireless_ptr=nwk_get_rand()%pGateWay->wireless_num;//随机选择天线
//            printf("wireless_ptr=%d\n", pNodeTxGw->wireless_ptr);
            
          }             
          printf("wireless_ptr=%d\n", pNodeTxGw->wireless_ptr);
          printf_oled("wireless_ptr=%d\n", pNodeTxGw->wireless_ptr);
          pNodeTxGw->tx_state=NwkNodeTxStaticInit;//下一步,静态参数初始化   
        }
    case NwkNodeTxStaticInit://静态参数初始化
    {
      u8 sf=0, bw=0;
      nwk_get_static_channel4(pNodeTxGw->wireless_ptr, &sf, &bw);
      u8 freq_ptr=pNodeTxGw->pGateWay->base_freq_ptr + pNodeTxGw->wireless_ptr*NWK_GW_SPACE_FREQ;//计算频率序号,每根天线间隔2MHz
      pNodeTxGw->freq=NWK_GW_BASE_FREQ+freq_ptr*1000000;//目标天线监听频率   
      pNodeTxGw->sf=sf;      
      pNodeTxGw->bw=bw;      
      nwk_node_set_lora_param(pNodeTxGw->freq, sf, bw);
      printf("NwkNodeTxStaticInit P(%.2f, %d, %d)\n", pNodeTxGw->freq/1000000.f, pNodeTxGw->sf, pNodeTxGw->bw);
      u8 first_buff[20]={0};
      u8 first_len=0;
      first_buff[first_len++]=0xA7;
      first_buff[first_len++]=g_sNwkNodeWork.node_sn>>24;
      first_buff[first_len++]=g_sNwkNodeWork.node_sn>>16;
      first_buff[first_len++]=g_sNwkNodeWork.node_sn>>8;
      first_buff[first_len++]=g_sNwkNodeWork.node_sn;
      first_buff[first_len++]=pNodeTxGw->tx_len;//发送长度
      first_buff[first_len++]=nwk_get_rand();//随机数1
      first_buff[first_len++]=nwk_get_rand();//随机数2  这里暂且明文发送
      nwk_node_send_buff(first_buff, first_len);//发送抢占包
      u32 tx_time=nwk_calcu_air_time(pNodeTxGw->sf, pNodeTxGw->bw, first_len);//发送时间,冗余
      pNodeTxGw->start_rtc_time=nwk_get_rtc_counter();//记录当前时间,防止超时
      pNodeTxGw->wait_cnts=tx_time/1000+1;//等待秒数
      pNodeTxGw->tx_state=NwkNodeTxStaticFirstCheck;
      printf("send first buff! tx_time=%ums\n", tx_time);         
      break;
    }
    case NwkNodeTxStaticFirstCheck://发送抢占包检测
    {
      u32 now_time=nwk_get_rtc_counter();
      u8 result=nwk_node_send_check();//发送完成检测
      if(result)//发送完成
      {
        printf("tx_first ok!\n");
        pNodeTxGw->freq=nwk_get_sn_freq(g_sNwkNodeWork.node_sn);//根据序列号计算频段
        nwk_node_set_lora_param(pNodeTxGw->freq, pNodeTxGw->sf, pNodeTxGw->bw);
        printf("into recv, wait ack, P(%.2f, %d, %d)\n", pNodeTxGw->freq/1000000.f, pNodeTxGw->sf, pNodeTxGw->bw);
        nwk_node_recv_init();//进入接收,等待回复
        u32 tx_time=nwk_calcu_air_time(pNodeTxGw->sf, pNodeTxGw->bw, 20);//接收回复包等待时间
        pNodeTxGw->start_rtc_time=nwk_get_rtc_counter();//记录当前时间,防止超时
        pNodeTxGw->wait_cnts=tx_time/1000+2;
        pNodeTxGw->tx_state=NwkNodeTxStaticFirstAck;		
      }
      else if(now_time-pNodeTxGw->start_rtc_time>pNodeTxGw->wait_cnts)//发送超时
      {
        printf("tx first time out! wait time=%ds\n", pNodeTxGw->wait_cnts);
        pNodeTxGw->tx_state=NwkNodeTxStaticExit;  
      }      
      break;
    }
    case NwkNodeTxStaticFirstAck://抢占包回复检测
    {
      u32 now_time=nwk_get_rtc_counter();
      u8 recv_len=nwk_node_recv_check(g_sNwkNodeWork.node_rx.recv_buff, &g_sNwkNodeWork.rf_param);
      if(recv_len>0)
      {
        //数据解析
        u8 *pBuff=g_sNwkNodeWork.node_rx.recv_buff;
				printf("first ack rssi=%ddbm, snr=%ddbm\n", g_sNwkNodeWork.rf_param.rssi, g_sNwkNodeWork.rf_param.snr);
				printf_hex("ack=", pBuff, recv_len);
        u8 head[1]={0xA7};
        u8 *pData=nwk_find_head(pBuff, recv_len, head, 1);
        if(pData)
        {
          pData+=1;
          u32 dst_sn=pData[0]<<24|pData[1]<<16|pData[2]<<8|pData[3];
          pData+=4;
          printf("dst_sn=0x%08X\n", dst_sn);
          if(dst_sn==g_sNwkNodeWork.node_sn)
          {
            nwk_node_send_buff(make_buff, make_len);//发送数据包
            u32 tx_time=nwk_calcu_air_time(pNodeTxGw->sf, pNodeTxGw->bw, make_len);//发送时间,冗余
            pNodeTxGw->start_rtc_time=nwk_get_rtc_counter();//记录当前时间,防止超时
            pNodeTxGw->wait_cnts=tx_time/1000+2;//等待秒数	
            pNodeTxGw->tx_state=NwkNodeTxStaticAppCheck; 
            printf("send app buff\n");
            printf_oled("tx app wire=%d\n", pNodeTxGw->wireless_ptr);		            
          }
          else
          {
            printf("sn error!\n");
            pNodeTxGw->tx_state=NwkNodeTxStaticExit;             
          }
        }
      }   
      else if(now_time-pNodeTxGw->start_rtc_time>pNodeTxGw->wait_cnts)//超时,可能没有抢占到
      {
				printf("wait first ack time out!\n");  
        pNodeTxGw->tx_state=NwkNodeTxStaticExit;
      }       
      break;
    }
    case NwkNodeTxStaticAppCheck://发送数据包检测
    {
      u32 now_time=nwk_get_rtc_counter();
      u8 result=nwk_node_send_check();//发送完成检测
      if(result)//发送完成
      {
        printf("tx app ok, recv ack!\n");
        nwk_node_recv_init();//进入接收,等待回复
        u32 tx_time=nwk_calcu_air_time(pNodeTxGw->sf, pNodeTxGw->bw, 20);//接收回复包等待时间
        pNodeTxGw->start_rtc_time=nwk_get_rtc_counter();//记录当前时间,防止超时
        pNodeTxGw->wait_cnts=tx_time/1000+2;
        pNodeTxGw->tx_state=NwkNodeTxStaticAppAck;		
      }
      else if(now_time-pNodeTxGw->start_rtc_time>pNodeTxGw->wait_cnts)//发送超时
      {
        printf("tx app time out! wait time=%ds\n", pNodeTxGw->wait_cnts);
        pNodeTxGw->tx_state=NwkNodeTxStaticExit;  
      }      
      break;
    }
    case NwkNodeTxStaticAppAck://等待网关回复确认
    {
      u32 now_time=nwk_get_rtc_counter();
      u8 recv_len=nwk_node_recv_check(g_sNwkNodeWork.node_rx.recv_buff, &g_sNwkNodeWork.rf_param);
      if(recv_len>0)
      {
        //数据解析
				printf("tx ack rssi=%ddbm, snr=%ddbm\n", g_sNwkNodeWork.rf_param.rssi, g_sNwkNodeWork.rf_param.snr);
        srand(g_sNwkNodeWork.rf_param.rssi);
				printf_hex("ack=", g_sNwkNodeWork.node_rx.recv_buff, recv_len);
        nwk_node_recv_parse(g_sNwkNodeWork.node_rx.recv_buff, recv_len);
        printf_oled("*tx ok! wire=%d", pNodeTxGw->wireless_ptr);
      }   
      else if(now_time-pNodeTxGw->start_rtc_time>pNodeTxGw->wait_cnts)//超时
      {
				printf("wait ack time out!\n");  
        pNodeTxGw->tx_state=NwkNodeTxGwExit;
      }      
      break;
    }
    case NwkNodeTxStaticExit://静态退出
    {
      pNodeTxGw->try_cnts++;
        printf("static try_cnts=%d\n", pNodeTxGw->try_cnts);        
      if(pNodeTxGw->try_cnts>=3)//结束发送
      {
        nwk_node_clear_tx();       
        pNodeTxGw->alarm_rtc_time=0xFFFFFFFF;//可以进入休眠
      }
      else
      {
        u32 now_time=nwk_get_rtc_counter();
        pNodeTxGw->wait_cnts=nwk_get_rand()%10;//随机延时,再次尝试
        pNodeTxGw->start_rtc_time=now_time;
        pNodeTxGw->alarm_rtc_time=now_time+pNodeTxGw->wait_cnts;//闹钟时间        
        if(pNodeTxGw->wireless_ptr>=pNodeTxGw->pGateWay->wireless_num)//结束发送
        {
          nwk_node_clear_tx();       
          pNodeTxGw->alarm_rtc_time=0xFFFFFFFF;//可以进入休眠
          printf_oled("static tx failed!");
        }
        else
        {
          printf("tx wait time=%ds\n", pNodeTxGw->wait_cnts);        
          printf_oled("tx wait time=%ds\n", pNodeTxGw->wait_cnts);             
        }
        printf("static alarm time=%us\n", pNodeTxGw->alarm_rtc_time);
      }
      nwk_node_set_led(false);//指示灯灭
      pNodeTxGw->tx_state=NwkNodeTxGwIdel;//回合结束
      break;
    }
5.3 动态发送

        动态发送麻烦一些,要进行速率自适应操作。选定合适的参数后,首先发送嗅探帧,所谓嗅探帧如下图所示,发送一字节,5ms后强制停止。

        嗅探10次,增加成功率,嗅探结束后立马以增加1M频率的参数进入CAD检测回复信号,例如嗅探是(470.25, 11, 8),那么监听参数就是(471.25, 11, 8),跳频减少干扰。回复监听次数是10次,也是为了增加成功率,因为两者在节奏上没那么合拍,就需要增加次数的方式提高成功率,这也是跟单纯专利方法的区别,理论和实践上的差距。

        如果CAD有回复的话,说明与目标天线握手成功了,就可以进行数据发送了,如果这次不成功,那么就再尝试握手一次,即多次嗅探,增加成功率,整个过程其实很快,一轮几百ms时间,随着通道ID增加,时间也会增加,所以后面的轮次也减少了。

        无论静态模式还是动态模式,网关天线从机都有对应的任务配合节点传输,从天线从机角度看,那就是接收函数了,如下图所示:

                如果搜索到CAD信号就会返回嗅探帧并进入接收模式,如下图所示。

        握手成功后,就进入应用数据发送了,节点首先会发送一个前导包,里面包含将要发送数据的长度,这样网关天线这边就可以明确自己要等待的时间了,如果直接发送应用数据,因为干扰等原因又没收到,那么网关天线就要按最长数据发送时间等待,很浪费时间。

        节点发送完前导包后会立马发送应用数据包,顺利的话,等待网关回复确认信号就可以完成本回合的发送任务了,通知应用层发送成功。

5.4 下行尾随

        对于唤醒周期为0xFFFF的节点,本质上是不进行唤醒监听的,对于这类设备要发送下行数据的话只能是主动上报的时候顺带下发,为了实现这一功能,网关在回复上行数据包的时候会检查该设备是否有缓存的下行数据,如果有的话会把数据包长度写入,这样节点就知道自己要等多久了,同时将下行包打包好,一同传给天线从机,从机发送完回复包后会继续发送应用数据包,完成下行数据的发送。这一功能适用于所有类型的节点,不一定是休眠设备。

六、数据下行
6.1 网关下行管理

        下行数据根据节点休眠周期分为三种:不休眠、周期唤醒和长休眠。对于长休眠设备刚才5.4节已经描述过了,周期唤醒和不休眠设备有点类似,区别在于周期唤醒设备要多个唤醒步骤,发送唤醒嗅探帧。这一步在天线从机的发送函数里完成。

        对于网关主机来讲,它要根据节点的唤醒周期来判断能否下发,首先在间隔上不能太频繁,影响系统稳定,这里是要间隔10秒的;其次是时间戳是否为唤醒周期的整数倍,箭头指示的时间+1目的是提前1秒发送,确保不会错过。

6.2 从机发送

        从机接收到下行数据指令后,根据唤醒标志决定是否发送唤醒包,针对不休眠设备可以省去这一步,节约时间。发送完唤醒帧后就直接发送匹配包,告诉被唤醒的节点:网关这次是要跟谁通讯,无关的节点继续休眠。因为如果唤醒周期一样的话是有可能会被同时唤醒的,为了减少这种情况,唤醒周期尽量在某个范围内用随机值代替,比如阀门的唤醒周期可以5~10秒之间,那就节点自行产生5~10的随机数作为唤醒周期,入网后就不需要改变了。

        另外,下行唤醒的频率是根据节点的SN计算的,会有冲突的可能性,但是加上随机的唤醒周期,同频又同周期的可能性就大大减少了。

        发送完匹配包后,就进入速率自适应阶段了,这个流程跟上行数据时是一样的,只不过角色互换了,现在是天线从机发送嗅探包,节点监听扫描,整个流程是一致的。匹配成功后会直接发送应用数据包,最后就是等待回复确认信号了,天线从机方面下行任务完成。

        在网关主机方面,这里配合端点物联APP,还会把下发结果上报给APP端,主要有下图几种状态,如果成功发送就是返回下发成功。

6.3 节点接收

        在节点端,如果SN匹配的话就会进入速率匹配阶段,同时也获取到了将要发送的应用数据长度,这在后续中会用到,可以明确等待时间。

        速率匹配成功后就进入接收模式了,根据之前收到的待发送数据长度确定等待时间。如果后续收到数据解析正确,就会返回确认包,完成下行接收任务。

七、设备间发送(D2D)

        设备间发送本质就是让发送方充当临时网关,在软件和硬件上都很好兼容,而传统的LoRaWAN协议是没有D2D能力的,后面好像阿里有个补充协议,有点曲线救国了。D2D在设备联动的场景比较有用,实时性和可靠性都提升了。

        这部分代码发送和接收都是在节点端,接收部分是复用的,就是增加个D2D发送程序。发送前首先应用层要添加数据包,同时携带目标SN和唤醒周期参数,如下图所示:

        确保无误后,在主任务中检查周期是否到达,到达后进入发送流程。

        随后流程跟下行数据一样,唤醒--SN匹配--速率自适应--发送应用数据--回复确认,具体可以看项目里的代码,这里就不贴了。

八、OLED屏幕
8.1 OLED驱动

        为了方便节点调试,除了增加电池供电的以外,还增加了一个OLED屏幕,可以展示节点的状态信息,具体内容在第二篇中已经有详细解释了,这里主要讲解下代码。

        代码包含驱动和应用两部分,这个屏幕通讯接口采用IIC,为了增加速度,采用了硬件IIC,下图是两个相关文件。

        驱动层主要是对屏幕的寄存器进行设置,比如下面的初始化函数,这个根据厂家的代码来就行了。

        为了增加可移植性,IIC相关的代码都是在应用层写的,驱动层只是提供注册接口,把相关函数注册即可。

        其他就是功能性函数了,根据厂家提供的代码改下命名风格就行了。

8.2 OLED应用

        下图是OLED的IIC初始化和写函数,网上有相关代码,或者看ST的demo库。

        剩下的就是根据应用需求,在指定位置显示指定内容,像这里就是显示节点信息、信号强度、时间等信息了。

        这里有个函数需要提下,就是下图所示函数,他是用来显示打印信息的,类似于串口的printf。但是因为效率原因,没有直接在函数内显示,而是将待显示的字符串先缓存,然后在自己的显示线程里打印。因为这个函数主要是要显示协议栈运行过程的关键信息,比如接收到的数据,发送状态等等,这是在协议栈内调用的,如果直接显示,由于IIC速度较慢,会造成协议栈运行阻塞,导致CAD检测成功率下降。

        为了避免这个问题,我在OLED应用程序里定义了一个结构体,用于缓存节点状态信息,在协议栈线程中将这些信息缓存过来,由于显示任务的优先级比较低,这样就不会影响协议栈运行了。

缓存结构体

缓存状态信息

显示任务

九、应用配置
9.1 节点配置

        节点配置主要在这个函数里,自己根据注释和需求修改,SN和唤醒周期可以通过串口修改并保存,搜索周期和时长只能代码修改了。根密码要跟网关保持一致,这里只是举例,比较随意。

9.2 网关配置

        网关配置在这里修改,内容比较少,包括起始频段、运行模式和广播偏移,其中起始频段和运行模式可以利用端点物联APP修改。

9.3 从机配置

        从机没什么配置内容,就一个从机地址,可以通过调试串口更改,出厂时已经设置好,必须严格按照PCB上的序号顺序设置,所以一般也不需要动。

        所以,LoRaSun协议栈的使用是极其简单的,应用层基本也无需怎么配置。

十、节点运行逻辑
10.1 任务模块

        有了以上那些程序模块以后,剩下的问题就是如何把它们串起来,流畅运行了。具体看下面这个函数:


/*		
================================================================================
描述 : 工作状态检测
输入 : 
输出 : 
================================================================================
*/ 
void nwk_node_work_check(void)
{
  static NwkNodeTxGwStruct *pNodeTxGw=&g_sNwkNodeWork.node_tx_gw;
  static NwkNodeTxD2dStruct *pNodeD2d=&g_sNwkNodeWork.node_tx_d2d;  
  static NwkNodeRxStruct *pNodeRx=&g_sNwkNodeWork.node_rx;
  static NwkNodeSearchStruct *pNodeSearch=&g_sNwkNodeWork.node_search;
  
	switch(g_sNwkNodeWork.work_state)
	{
		case NwkNodeWorkIdel://空闲 
		{
      //网关数据检查
      if(pNodeTxGw->tx_len>0)//是否有网关数据需要发送
      {
        u32 now_time=nwk_get_rtc_counter();
        if(now_time-pNodeTxGw->start_rtc_time>=pNodeTxGw->wait_cnts)//随机延时结束
        {
          printf("tx gw wait ok!  time=%us\n", now_time);
          pNodeTxGw->alarm_rtc_time=0;
          pNodeTxGw->tx_state=NwkNodeTxGwInit;//重新开始
          g_sNwkNodeWork.work_state=NwkNodeWorkTXGw;
        }         
      }			
      //D2D数据检查
      if(g_sNwkNodeWork.work_state==NwkNodeWorkIdel)//仍旧空闲--D2D
      {
        if(pNodeD2d->tx_len>0)//是否有D2D数据需要发送
        {
          u32 now_time=nwk_get_rtc_counter();
          u16 wake_period=pNodeD2d->wake_period;
          if(wake_period==0)
            wake_period=1;
          if((now_time+1)%wake_period==0)//唤醒周期到 提前1秒
          {
            printf("tx d2d wait ok! time=%us\n", now_time);
            pNodeD2d->alarm_rtc_time=0;
            pNodeD2d->d2d_state=NwkNodeTxD2dInit;//开始
            g_sNwkNodeWork.work_state=NwkNodeWorkTXD2d;         
          }
        }
      }
      if(g_sNwkNodeWork.work_state==NwkNodeWorkIdel)//仍旧空闲--入网检查
      {
				u32 now_time=nwk_get_rtc_counter();
        for(u8 i=0; i<NWK_GW_NUM; i++)
        {
          NwkParentWorkStrcut *pGateWay=&g_sNwkNodeWork.parent_list[i];
          if(pGateWay->gw_sn>0)
          {
            if(pGateWay->wait_join_time==0)
            {
              pGateWay->wait_join_time=nwk_get_rand()%10+3;//首次入网等待时间
              pGateWay->last_join_time=now_time;
            }          
            if(pGateWay->join_state==JoinStateNone && 
               now_time-pGateWay->last_join_time>pGateWay->wait_join_time)
            {
              printf("pGateWay=0x%08X, join_state=%d\n", pGateWay->gw_sn, pGateWay->join_state);          
              pGateWay->wait_join_time=nwk_get_rand()%60+60;
              pGateWay->last_join_time=now_time;
              nwk_node_req_join(pGateWay->gw_sn);//请求入网
              return;
            }            
          }
        }        
      }      
      //搜索检查
      if(g_sNwkNodeWork.work_state==NwkNodeWorkIdel)//仍旧空闲--搜索
      {
        u32 now_time=nwk_get_rtc_counter();
        if(pNodeSearch->search_start_time==0  || //起始
           now_time<TIME_STAMP_THRESH ||  //时间未同步
          (pNodeSearch->period>0 && now_time%pNodeSearch->period==0) )//周期到达
        {
          pNodeSearch->search_start_time=now_time;
          pNodeSearch->search_state=NwkNodeSearchInit;
          g_sNwkNodeWork.work_state=NwkNodeWorkSearch;//进入搜索状态             
        }
        
      } 
      //接收检查
      if(g_sNwkNodeWork.work_state==NwkNodeWorkIdel)//仍旧空闲--接收
      {
        if(g_sNwkNodeWork.wake_period==0)//无需休眠
        {
          pNodeRx->alarm_rtc_time=0;//不休眠
          pNodeRx->rx_state=NwkNodeRxInit;
          g_sNwkNodeWork.work_state=NwkNodeWorkRX;  //进入接收监听      
        }
        else if(g_sNwkNodeWork.wake_period==0xFFFF)//无需监听
        {
          pNodeRx->alarm_rtc_time=0xFFFFFFFF;//不唤醒
        }
        else
        {
          static u32 last_wake_time=0;//上次唤醒时间,避免单次周期内重复唤醒
          u32 now_time=nwk_get_rtc_counter();
          if(now_time%g_sNwkNodeWork.wake_period==0 && now_time!=last_wake_time)
          {
						printf("wake!!!  time=%us\n", now_time);
//						printf_oled("wake!!!  time=%us\n", now_time);
            pNodeRx->rx_state=NwkNodeRxInit;
            pNodeRx->alarm_rtc_time=now_time+g_sNwkNodeWork.wake_period;//下一次唤醒周期
      			g_sNwkNodeWork.work_state=NwkNodeWorkRX;//进入接收监听  
            last_wake_time=now_time;
          }
        }        
      }

    
			break;
		}
    
    /******************************/
		case NwkNodeWorkSearch://搜索  1
		{
			nwk_node_search_process();
      if(pNodeSearch->search_state==NwkNodeSearchIdel)//单回合结束
      {
        g_sNwkNodeWork.work_state=NwkNodeWorkIdel;
      }       
			break;
		}
		case NwkNodeWorkRX://接收   2
		{
			nwk_node_rx_process();
      if(pNodeRx->rx_state==NwkNodeRxIdel)//单回合结束
      {
        g_sNwkNodeWork.work_state=NwkNodeWorkIdel;
      }      
			break;
		}
		case NwkNodeWorkTXGw://发送到网关  3
		{
			nwk_node_tx_gw_process();
      if(pNodeTxGw->tx_state==NwkNodeTxGwIdel)//单回合结束
      {
        g_sNwkNodeWork.work_state=NwkNodeWorkIdel;
      }
			break;
		}    
		case NwkNodeWorkTXD2d://发送到设备   4
		{
			nwk_node_tx_d2d_process();
      if(pNodeD2d->d2d_state==NwkNodeTxD2dIdel)//单回合结束
      {
        g_sNwkNodeWork.work_state=NwkNodeWorkIdel;  
      }
			break;
		}       
	}
}

        简单讲就是根据任务的优先级,配合状态机运行各个子模块。根据代码,在空闲时会检查各个模块的状态,其中上行发送是第一优先级,然后是D2D、入网、搜网,最后才是接收任务。各个子模块结束后都会切换到空闲状态,主任务检测到子模块空闲后就会进入整体空闲,检查各个模块的任务状态。

        如下图所示,主任务有自己的状态,各个模块也有自己的状态。

10.2 闹钟计算

        对于需要休眠的节点,协议栈要告诉应用层什么时候要休眠、什么时候要唤醒,因为一个系统还有其他外设模块和功能需求,应用层要综合所有外设的休眠需求决定休眠、唤醒时刻。这里核心来讲就是一个唤醒闹钟,只要告诉应用层需要在什么时刻叫醒我就行了,我协议栈先去睡觉了,其他的你们自己看着办吧。协议栈的唤醒闹钟需要综合子模块的闹钟才能计算出来,所以在每个功能模块的结构体里都有一个闹钟时间,主任务把他们中最小那个记录下来报告给应用层就行了。

结构体里的闹钟

节点唤醒时间计算

        在应用层,进行了简单的唤醒休眠操作,由于这个测试板不适合低功耗测试,就没有进行整体的低功耗配置了。低功耗开发需要软硬件配合,有点难度,后续会出相关项目教程。

10.3 事件通知

        节点入网、数据发送结果都会通过事件的方式通知应用层,让应用层进行相应的处理,其实原理很简单,就是定义一个结构体,包含事件类型和参数,不同的事件事先定义好,有什么参数也都知道,这样在协议栈内部对结构体赋值,应用层通过检查这个结构体变量就能知道协议栈内部通知的事件了。下面是事件结构体和事件类型,以及应用层的检查处理函数。

事件结构体和类型

应用层事件处理函数

        对于网关,道理其实也是一样的。

十一、总结

        这篇文章比较长,基本上把协议栈的逻辑和内容解释一遍了,细节的东西实在没法写,可能后续会出个视频讲解下,会通过公众号通知。

        在理解了上一篇专利的基础上,代码理解起来应该也很简单的,主要是内容比较多,单纯节点协议栈的代码也有两千多行了,细节的东西只能代码慢慢看了。

        后面如果有时间就继续讲解下LoRa硬件的移植和驱动过程,同时了解下什么是扩频原理,毕竟这一优势大家只是口口相传,很少有人深入数学和通讯原理去讲解的。

        最后需要声明一点的是,本协议栈完全是个人兴趣爱好驱动完成的,并没有要去横向对比LoRaWAN等成熟的协议栈,这里仅提供一个自组网思路,以供学习研究使用

        本专栏地址:https://blog.csdn.net/ypp240124016/category_12834955.html

        本人的其它文章导航地址:端点物联网学习资源合集-CSDN博客 

        关注VX公众号 端点物联,以便即时接收文章更新信息。

        学习交流QQ群:  701889554

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐