GB28181实现

  • Post author:
  • Post category:其他


前几篇以3399平台大致讲解了一些视频的概念及应用,考虑到大家使用平台的通用性,接下来提供的附件以x86 ubuntu18.04为运行平台。

GB28181主要用于安防场景,目前电力行业也逐步引入了该标准。与B接口(后续章节可能会给大家普及)相似,都是基于sip指令的交互,完成视频的转发,控制,历史查询等(这两个标准实际上,也是互相借鉴补充,如B接口2019也开始引入了基于tcp通道的视频播放)。

本篇以GB28181-2016为基础讲解,2011老版本也逐步被取代了。篇幅有限,只讲解视频部分,这其实也是B接口及281最难的部分。该部分完成后,其余部分,也就水到渠成了。重要一点,本篇讲解的是网关层,非平台层

一、准备

1.1 开源库

站在巨人的肩膀上会看的更高,所以针对这个协议,从头撸代码是困难的。所幸存在很多优秀的开源代码供我们选择使用。推荐的开源库使用如下:

osip:开源完整的osip协议栈实现,但接口较复杂

eXosip:osip的二次封装,使用起来更加简单。所以两者往往一起使用,推荐也是一起编译使用

tinyxml:xml解析开源库,我们组装sip信令少不了它

jrtplib:rtp实现库,基于它可以很方便的基于rtp发送流

jthread:jrtplib的补充库,可使rtp多线程,提高性能效率

ffmpeg:之前文章介绍过,完整的视频协议库。有人问,我就用ffmpeg不能实现rtp流的发送吗?可以,但是专业库做专业的事会更快。

有这些开源库,我们就可以着手进行下一步了

1.2 协议阅读

官方的文档内容比较多,我们要有针对性的去阅读,并不需要每个章节每个章节的过。第九章开始可以好好阅读,这里不仅描述了功能,更提供了交互流程图。

然后附录C,基于RTP的视音频数据封装及附录J信令消息示范是需要看的,这样能对比着交互报文,查看有哪些错误。

二、协议实现

一切准备就绪后,可以准备代码编写了,我们按照步骤进行

2.1 注册

注册的流程,osip已经帮我们做好了,只需要按照接口传入参数即可。

bool ICommGB::registerSV(bool bReg)
{
    osip_message_t *reg = NULL;
    eXosip_set_user_agent(m_pCtx, "PncSip");
    std::stringstream strInfo;
    const std::string &sipUser = m_cfgInfo["sipUser"].asString();
    const std::string &sipUserID = m_cfgInfo["sipUserID"].asString();
    const std::string &sipPasswd = m_cfgInfo["sipPasswd"].asString();

    /// 添加用户注册信息
    if (eXosip_add_authentication_info(m_pCtx, sipUser.c_str(), sipUserID.c_str(), sipPasswd.c_str(), NULL, NULL))
    {
        errorf("add auth failed\n");
        return false;
    }
    int localPort = m_cfgInfo["sipLocalPort"].asInt();
    strInfo << "sip:" << sipUser << "@" << m_localIP << ":" << localPort;
    const std::string &strContent = strInfo.str();
    int expires = m_cfgInfo["sipExpires"].asInt();

    std::stringstream streamProxy;
    const std::string &remoteIP = m_cfgInfo["sipRemoteIP"].asString();
    int remotePort = m_cfgInfo["sipRemotePort"].asInt();
    /// 拼装代理信息,有两种,一个是填remote的域,一个是填remote的ip及端口,我们选择第二种
    streamProxy << "sip:" << remoteIP << ":" << remotePort;
    const std::string &proxy = streamProxy.str();
    eXosip_lock (m_pCtx);
    int regId = 0;

    /// 进行注册流程,这里参数都是固定的,按照实际内容填写即可。注意当expires为0时代表注销
    if (bReg)
    {
        regId = eXosip_register_build_initial_register(m_pCtx, strContent.c_str(), proxy.c_str(), strContent.c_str(), expires, &reg);
    }
    else
    {
        regId = eXosip_register_build_initial_register(m_pCtx, strContent.c_str(), proxy.c_str(), strContent.c_str(), 0, &reg);
    }

    if (regId < 0)
    {
        errorf("init reg failed, ret[%d]\n", regId);
        eXosip_unlock(m_pCtx);
        return false;
    }
    // osip_message_set_supported (reg, "100rel");
    // osip_message_set_supported (reg, "path");
    eXosip_register_send_register (m_pCtx, regId, reg);
    eXosip_unlock (m_pCtx);

    /// 最后保存fromInfo及toInfo,后面发送message时会用到
    m_fromInfo = strContent;
    const std::string &remoteID = m_cfgInfo["sipRemoteID"].asString();
    std::stringstream streamTo;
    streamTo << "sip:" << remoteID << "@" << remoteIP << ":" << remotePort;
    m_toInfo = streamTo.str();
    return true;
}

注册成功后,就可以进行服务绑定了

    int ret = eXosip_listen_addr(m_pCtx, IPPROTO_UDP, m_localIP.c_str(), localPort, AF_INET, 0);
    if(ret != 0)
    {
        errorf("listen sip failed, port[%d]\n", localPort);
        eXosip_quit(m_pCtx);
        return false;
    }

2.2 数据交互

注册成功后,进入数据交互阶段。一般情况下,当设备注册成功后,平台会立即进行一次设备信息查询以及设备目录查询(有些平台可能只查询目录),以同步设备的具体拓扑信息。

首先查询设备信息,Message中CmdType为DeviceInfo,这里是描述的当前设备的信息,xml示例如下

注意前后回复的SN号,DeviceID需要与平台下发保持一致

紧接着目录查询,CmdType为Catalog,这里描述的就是当前设备所挂载的节点(摄像头)。xml交互示例如下

注意此处的SN及DeviceID保持一致外,SumNum为挂载节点的总数,DeviceList Num为此次上传的总数,两者不一定相等,也就是可以存在分包上送的情况。因为网络的限制或者说平台的限制,有时候需要分包上送,比如挂载了五个摄像头,可以分五次上送。也可以2,2,1的划分。分包期间,SN号等仍然需要保持一致。最终DeviceList Num的上传综合,需要等于SumNum。

这些数据与平台交互后,平台上已经能看到你设备的注册及拓扑信息了。28181为了保持设备一直在线,需要有一个保活的功能,一般一分钟一次。起一个定时器定时发送保活的xml即可。

void CGB28181::doLeft()
{
    int heartBeat = m_cfgInfo["heartBeat"].asInt();
    m_keepLive.start(base::function(&CGB28181::keepLive, this), heartBeat * 1000, true);
}

至此,第一阶段注册数据部分已经完成,可以开始最难的视频部分了

2.2 视频预览

此部分是281最重要的功能,这也是客户验收的硬性要求,我们仅实现视频预览功能,不考虑音频流的传输。首先看协议视频部分描述

由协议可知,281支持两种类型的视频封装,一个是PS,一个是RTP裸载视频数据,其中第二部分又分MPEG-4,H264,SVAC。我们的选择面就广了,哪种方便实现哪种,但最终结果告诉我们对接平台时可能对接不上。所以理论归理论,实践归实践,一般情况下,PS流是281的主流支持方式,所以我们无脑就实现PS流就行,防止平台仅实现PS流,我们对接不上平台的尴尬。(注,如果同时考虑到B接口的实现,建议还是实现H264的RTP封装,这个是B接口的默认流方式)。

既然方案确定了,那就考虑去实现PS流了,此流封装确实比较麻烦。不过好在,我们对于一些字段是不需要灵活定义的,都是可以写死的。所以也有很多网友提供了PS流的实现。实现流封装之前,先搭建RTP的的通道环境

由于我们选择了jrtplib库,内部已经封装了RTP实现,所以我们写起来还是比较方便的。有一点不便,也是需要注意的。281支持流的TCP方式传输,也就是需要我们实现基于TCP的RTP方式,而jrtplib默认实现的是udp的RTP。好在该库已经留了相关接口,我们简单扩充即可。

bool CRtpVideo::rtpTrans::setupTCP(int localPort, const std::string &remoteIp, int remotePort, int payloadType, uint32_t ssrc)
{
    RTPSessionParams sessParams;
    sessParams.SetOwnTimestampUnit(1.0 / 90000.0);

    // sessParams.SetAcceptOwnPackets(true);
    sessParams.SetProbationType(RTPSources::NoProbation);
    int nPackSize = 1360;
    sessParams.SetMaximumPacketSize(nPackSize + 64);
    sessParams.SetUsePredefinedSSRC(true);  //设置使用预先定义的SSRC
    sessParams.SetPredefinedSSRC(ssrc);
    /// 初始化一个tcp转发器
    transMitter = new RTPTCPTransmitter(0);
    int ret = transMitter->Init(true);
    if (ret < 0)
    {
        errorf("setup rtp failed, msg[%s]\n", RTPGetErrorString(ret).c_str());
        return false;
    }
    transMitter->Create(65535, NULL);
    /// sess为RTPSession类型,将设置好的tcp类型设置进去
    ret = sess.Create(sessParams, transMitter);
    // int ret = sess.Create(sessParams, &transparams, RTPTransmitter::TCPProto);
    if (ret < 0)
    {
        errorf("setup rtp failed, msg[%s]\n", RTPGetErrorString(ret).c_str());
        return false;
    }
    infof("payType is %d, ssrc[%u], remoteIP[%s], remotePort[%d]\n", payloadType, ssrc, remoteIp.c_str(), remotePort);
    sess.SetDefaultPayloadType(payloadType);//设置传输类型
    sess.SetDefaultMark(true);      //设置位
    sess.SetTimestampUnit(1.0 / 90000.0); //设置采样间隔
    sess.SetDefaultTimestampIncrement(3600);//设置时间戳增加间隔

    /// 该段就是启动一个tcp服务了,这个是需要我们自己启动的,rtplib内部不实现
#if 1
    clientId = socket(AF_INET, SOCK_STREAM, 0);
    fcntl(clientId, F_SETFL,fcntl(clientId,F_GETFL,0) | O_NONBLOCK);
    sockaddr_in mine, serverAddr;

    bzero(&mine, sizeof(mine));
    mine.sin_family = AF_INET;
    mine.sin_port = htons(localPort);
    bind(clientId, (struct sockaddr*)&mine, sizeof(mine));
    memset(&serverAddr, 0, sizeof(sockaddr_in));
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = inet_addr(remoteIp.c_str());
    serverAddr.sin_port = htons(remotePort);
    connect(clientId, (sockaddr *)&serverAddr, sizeof(serverAddr));
#endif

    uint32_t destip;
    destip = inet_addr(remoteIp.c_str());

    if (destip == INADDR_NONE)
    {
        errorf("bad ip[%s]\n", remoteIp.c_str());
        return false;
    }
    destip = ntohl(destip);

    RTPTCPAddress addr(clientId);
    /// 把remote的tcp地址设置成目的地,至此通道设置已经完成
    ret = sess.AddDestination(addr);
    if (ret < 0)
    {
        errorf("add dest[%s:%d] failed, masg[%s]\n", remoteIp.c_str(), remotePort, RTPGetErrorString(ret).c_str());
        return false;
    }
    H264 = payloadType;

    return true;
}

以上代码,基于的tcp的rtp已经搭建完成,udp更为简单,此处不贴出代码了。参考rtplib库的demo即可实现。接下来还要考虑一个rtp分包的问题,因为视频包一向比较大,一个mtu包(1500)是不可能全部发过去的。而RTP分包是需要额外设置一些参数的,PS的流分包比较简单,只需要分段包,并且设置好时间戳即可。如果是实现H264的RTP分包,会复杂一些,需要参考FU-A的方式进行分包。

封装的分包函数示例如下:

bool CRtpVideo::rtpTrans::sendFUA(uint8_t *data, int len, bool mark)
{
    int timeAdd = 0;
    /// 当mark为true时,为最后一个尾包,时间戳递增。
    if (mark)
    {
        timeAdd = 3600;
    }
    // infof("begin send\n");
    int num = 5;
    while (num > 0)
    {
        int ret = sess.SendPacket((void *)data, len, H264, mark, timeAdd);
        /// 400是自定义,内部修改了select超时返回。用户不必与此一致写代码判断
        if (ret == 400)
        {
            warnf("jrtp select timeout\n");
            num--;
            continue;
        }
        if (ret < 0)
        {
            errorf("send pkt failed, msg[%s]\n", RTPGetErrorString(ret).c_str());
            return false;
        }
        break;
    }
    // infof("end send\n");
    return true;
}

万事具备,就只剩PS封装了。

接下来ffmpeg可以发挥作用了,首先依然是读流,获取avpacket,之前文章已经描述过。拉流已经在视频组件实现了,直接注册回调获取包即可

void CRtpVideo::process()
{
    AVPacket *packet = NULL;
    infof("process GB video thread enter\n");
    while (m_thread.looping())
    {
        if (!m_quePkt.recvMessage(packet, 1000))
        {
            continue;
        }
        if (m_bTrans)
        {
            /// 判断平台的拉流类型
            if (m_flowType == "PS")
            {
                sendPS(packet);
            }
            else if (m_flowType == "H264")
            {
                sendH264(packet);
            }
        }
        packet释放
        av_packet_unref(packet);
        av_packet_free(&packet);
    }
}

void CRtpVideo::sendPS(AVPacket *&packet)
{
    /// 平台可能会发一个强制I帧显示
    if (m_bSendIframe)
    {
        if (packet->flags != AV_PKT_FLAG_KEY)
        {
            return;
        }
        m_bSendIframe = false;
    }
    bool bKey = false;
    if (packet->flags == AV_PKT_FLAG_KEY)
    {
        bKey = true;
    }
    /// 组装PS流
    m_psFlow.pack(packet->data, packet->size, bKey, packet->pts, packet->dts);
}

PS流组装发送,首先看一下PS流的组装方式

总结来说I帧:PS+SYSHEAD+PSM+PESV

其余帧:PS+PESV


注意,PES包也是由自己的头的,不要漏掉。不要认为PESV就是简单的裸视频数据

void CPsFlow::pack(uint8_t *data, int len, bool bKey, int64_t pts, int64_t dts)
{
    // dts = m_pts;
    memset(m_buf, 0, MAX_FRAME_SIZE);
    int nPos = 0;
    // dts = dts >= 3600 ? (dts - 3600) : 0;
    /// 添加PS Head
    addPsHead(m_buf, pts);
    nPos += PS_HDR_LEN;
    /// I帧特殊处理
    if (bKey)
    {
        /// 添加系统头
        addSysHead(m_buf + nPos);
        nPos += SYS_HDR_LEN;
        /// 添加PSM头
        addPsmHead(m_buf + nPos);
        nPos += PSM_HDR_LEN;
    }
    // addPesHead(m_buf + nPos, len, pts , pts);
    nPos += PES_HDR_LEN;
    memcpy(m_buf + nPos, data, len);
    nPos -= PES_HDR_LEN;
    int nSize = 0;
    int posHead = nPos + PES_HDR_LEN;
    uint8_t *tmp = m_buf;
    /// 开始分包
    while(len > 0)
    {
        tmp = m_buf + nPos;
        /// 每PS_PES_PAYLOAD_SIZE分一次包,此处值为1300
        nSize = (len > PS_PES_PAYLOAD_SIZE) ? PS_PES_PAYLOAD_SIZE : len;
        /// 添加PES头,组装PESV
        addPesHead(tmp, nSize, pts, dts);
        tmp -= posHead - PES_HDR_LEN;
        发送RTP的回调函数接口
        m_func(tmp, nSize + posHead, ((nSize == len) ? true : false));
        nPos += nSize;
        posHead = PES_HDR_LEN;
        len -= nSize;
    }
}

281视频部分已经介绍结束了,看下实际的交互报文

平台下发的RTP/AVP就代表TCP方式,这个在调试时需要注意

video后跟的字段30020为远程视频端口

回复的video 后的为本地视频端口,根据实际情况设置,这里为9000。其余的照葫芦画瓢即可,那个username和password这两个属性可以不填

最后需要注意的是,由于平台各家实现的细节不一样,有些字段有,有些没有。这样osip库可能无法正确解析获取字段,比如视频部分,y=这个ssrc字段,不改osip是解析不了的。包括多余的解析,也是需要修改注释掉的。这个时候,需要自己跟进去这个库进行修改。一般在sdp_message.c的sdp_message_parse函数中进行修改,比如我增加的解析ssrc字段

实现到这里,基本281的开发就没有难度了,包括后面的PTZ控制,历史查询等,都只是基于协议逻辑开发处理即可

二次开发接口及程序免费运行license请联系微信HardAndBetter获取,或者加入QQ群586166104讨论。

为了更好的学习281,demo下载地址:


https://download.csdn.net/download/z5201314100/85271657

没有积分可进行百度网盘下载,路径如下:

链接:https://pan.baidu.com/s/1LbGs9MXVXXEIBNmfL_AkyQ

提取码:4cp5



版权声明:本文为z5201314100原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。