前几篇以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, ®);
}
else
{
regId = eXosip_register_build_initial_register(m_pCtx, strContent.c_str(), proxy.c_str(), strContent.c_str(), 0, ®);
}
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