一、前言说明
用纯Qt来实现这个GB28181的想法很久了,具体可以追溯到2014年,一晃十年都过去了,总算是整体的框架和逻辑都打通了,总归还是杂七杂八的事情多,无法静下心来研究具体的协议,最开始初步了解协议后发现比onvif要复杂不少,索性先搁置一旁,所以先把onvif协议打通了,onvif协议好是好,但是一般在局域网内使用,外网访问几乎没有办法,而GB28181就是为了解决很多痛点定义的一套视频监控规范,毕竟现在满大街都是监控,各个部门机构都要外网远程取流,这就必须上国标,这其实是网络通信的弊端,服务端在没有收到过客户端的消息的时候,是无法得知客户端的具体通信地址,也就无法通信,需要先客户端主动给服务器发过消息才行。
关于sip协议的第三方库已经很多了,最终还是决定采用Qt底层的udp通信协议来解析,一方面可以大大加深协议的理解,提供友好的使用接口,不用大费周章的去编译各种第三方库,一方面考虑到后期的拓展,必须从底层手撸,一定要把兼容性易用性摆在第一位,这个最重要的点,也只有解决了客户的痛点,才能更好的卖钱。
1.1 个人理解
- gb2818协议是基于sip协议的一套协议框架,而sip是和http协议类似的一套基于udp/tcp载体的协议,最终底层通过udp/tcp收发数据。sip是一套多媒体通信框架,而gb28181就是在这套框架上,定义了具体的通信内容,也就是收发数据填充的内容。
- sip协议收发通常用的第三方开源库有osip/exosip,exosip依赖osip,是对osip的二次封装,带了具体传输层,osip相当于用来构建要收发的数据,本身没有收发功能。还有个更强大开源库pjsip,带了rtp解析。具体可参考 https://blog.csdn.net/weixin_43147845/article/details/144219082
- sip协议说复杂也复杂,说简单也简单,复杂就是涉及到的具体协议规约特别庞大,和http协议是并列的一种机制。简单就是如果只是少量的通信,可以直接用udp/tcp通信收发解析,要发送的数据每一行都回车换行,最后一起发出去即可。就是发送和解析不大方便,需要去寻找和取出关键字再处理,而开源库会给你处理好对应的数据结构,比如解析后有枚举字段直接判断。
- onvif是国际标准协议,gb28181是国家标准协议,各有优缺点,onvif通常是通过搜索到设备再去和设备通信,而gb28181刚好相反,是让设备主动连服务器,带上校验等参数,连上后,服务器再去和设备通信。这样相当于可以跨网了,而onvif通常只能局域网。gb28181还有个优势就是组网,可以层层级联,非常适合国内各种大监控系统的建设。
- onvif客户端是先udp组播获取到设备,然后再发送http请求到不同的地址来交互,请求中可以获取到视频流rtsp地址,然后自己用ffmpeg等框架打开这个地址播放就行。而gb28181服务端是先udp/tcp监听端口,对连上的设备进行sip协议格式的数据内容的交互,发送请求播放指令后,开启发送rtp数据包,再用ffmpeg等框架解析这个数据包就行。
- gb28181注册的时候Expires为有效期时间,一般是3600秒,如果是0则表示注销。
1.2 要点总结
- gb28181协议一般会选择udp通信,默认也是udp,早期国标设备都是只支持udp。
- 服务端开启端口监听,设备端填写好对应参数后,会尝试往对应端口发数据进行连接。
- 设备端间隔(心跳间隔默认是60s)发送REGISTER信令,服务端收到后,分析数据中是否带了鉴权信息(也就是用户认证相关信息),没有带的话则应答Unauthorized,带了的话,可以取出认证的信息,和要求的参数对比,比如国标服务端编号、认证密码、域编码信息,不一致则应答信息错误,叫客户端重新发。都没问题则表示认证通过。
- 认证后服务端发送MESSAGE信令,带上xml数据,获取设备信息,收到设备信息后,再去查阅目录也就是获取通道列表。
- 设备端在注册成功后,每隔一段时间发送一次心跳信息。方便服务端判断是否离线。
- 设备端一旦注册成功,在有效期(一般是3600s)内,不会再去注册,默认就是认为已经注册成功。所以有时候服务端这边开启服务后,未必先收到REGISTER注册指令,而是可能先收到的心跳指令,所以服务端这边要做个特殊处理,收到心跳后,先判断该设备在系统中是否已经存在,不存在则先获取设备信息,再去获取通信列表。
- 服务端支持多个设备注册,通过设备编号区分,严格要求同一个系统中设备编号不能重复,否则容易错乱。
- 每个设备都可以有多个视频通道,一般摄像头IPC只有1个通道,录像机NVR有多个通道。如果是国标级联,相当于把服务端当做一台NVR设备,每个设备的通道都转换成唯一标识的通道。
- 点播视频和云台控制等,都有个前提是要先获取到对应的通道列表,因为下发的数据中就要指定是哪个通道。
- 点播视频是服务端向设备端通过发送INVITE信令,带上sdp数据(具体sdp格式规范在gb28181-2016文档的第100页),sdp数据中包含了通道编号、音视频格式、音视频数据如何交互等信息。
- 服务端点播视频前,先要打开一个空闲的端口,这个端口号在sdp数据中带上,设备端收到点播指令后,会将音视频数据发给这个指定的端口,收到这些数据后再去用ffmpeg解码播放即可。sip这边只负责信令交互,并不负责音视频数据的通信。
- 关闭视频很关键,因为可能开了多个点播窗口,所以需要在点播视频后应答的ACK指令数据中,记住当时信令中的from/to/callid数据,在关闭视频的时候用播放时对应的这几个数据发给设备端,才能真正停止。
- 一个设备可能有多个通道,一个通道可能存在多个点播流,每个流都对应唯一的端口,所以需要有个队列记住这些点播流对应的ssrc/from/to/callid数据,可以指定关闭某一路流。
- 点播流需要对应端口接收流,一般这个端口需要动态分配,也可以不同流公用一个端口,公用端口不用担心数据会冲突,里面都是rtp的数据包,通过ssrc区分是哪一个流的数据包,这个ssrc是由点播发起者下发的,在sip指令中附加在subject属性上,sdp中有个y属性专门放这个ssrc字符串。在端口数量允许的情况下,一般建议每一路流都不同的端口,方便区分管理。
- 点播流的过程,一般第一步是先打开监听端口成功后,然后才将这个端口通过sip指令发给设备,因为端口有可能被占用,所以只有当打开监听端口成功的时候,再去点播流,这样才是通的,不然也是白搭。
- 语音对讲和点播视频流程不一样,是反着来的,先服务端发送语音广播指令Broadcast到设备,设备返回是否支持语音对讲,如果支持,会主动发送INVITE信令,带上sdp数据,服务端搜到这个sdp数据后解析,然后服务端主动往设备对应的端口发送带了语音数据的RTP数据包,设备端的声音会通过之前的音视频流传输过来。
- 云台控制和预置位相关处理就简单一些,因为都是单向操作。通过MESSAGE信令带上xml数据,数据中包含了要执行的通道编号和动作,这个动作的数据,是一个标准的固定长度8字节,16进制字符串数据格式,比如A5 0F 01 00 00 00 00 00,将要执行的动作替换对应的数据位即可,停止云台也是一个单独的动作。
- ffmpeg中并不能直接解码RTP数据包,需要解包后才是PS流才可以正确的解码,一般会用第三方开源库jrtp去实现解包,当然他也支持封包,发送语音数据的时候也要用到,jrtp直接就是带了网络通信,比如监听UDP端口收数据。
- 在信令交互过程中,可以多一些无关的数据,但是不能少一些必要的字段数据,比如invite信令必须带有Subject,缺少的话无法正常解析导致失败。
二、效果图
三、相关代码
#include "frmconfig.h"
#include "frmserver.h"
#include "ui_frmserver.h"
#include "qthelper.h"
#include "gb28181server.h"
#include "gb28181helper.h"frmServer::frmServer(QWidget *parent) : QWidget(parent), ui(new Ui::frmServer)
{ui->setupUi(this);this->initForm();this->initConfig();
}frmServer::~frmServer()
{delete ui;
}void frmServer::closeEvent(QCloseEvent *)
{if (server) {server->stop();}qApp->quit();
}bool frmServer::eventFilter(QObject *watched, QEvent *event)
{if (watched == ui->txtData->viewport() && event->type() == QEvent::MouseButtonDblClick) {this->appendMsg(0, "", true);}return QWidget::eventFilter(watched, event);
}void frmServer::initForm()
{QtHelper::replaceCRLF = false;ui->widgetControl->setEnabled(false);ui->widget->setFixedWidth(AppData::RightWidth);ui->txtData->viewport()->installEventFilter(this);ui->treeWidget->setAnimated(false);ui->treeWidget->setIndentation(15);ui->treeWidget->setExpandsOnDoubleClick(false);connect(ui->tabPreview, SIGNAL(selectVideo(QString, QString)), this, SLOT(selectVideo(QString, QString)));//立即启动服务server = NULL;if (AppConfig::ServerStart) {on_btnStart_clicked();}
}void frmServer::initConfig()
{ui->tabWidget->setCurrentIndex(AppConfig::TabIndex);connect(ui->tabWidget, SIGNAL(currentChanged(int)), this, SLOT(saveConfig()));ui->cboxDevice->addItem("0.0.0.0");ui->cboxDevice->lineEdit()->setText(AppConfig::FilterHost);connect(ui->cboxDevice->lineEdit(), SIGNAL(textChanged(QString)), this, SLOT(saveConfig()));
}void frmServer::saveConfig()
{AppConfig::TabIndex = ui->tabWidget->currentIndex();AppConfig::FilterHost = ui->cboxDevice->lineEdit()->text();AppConfig::writeConfig();
}void frmServer::appendMsg(int type, const QString &data, bool clear, bool pause)
{//最大行数和当前行数static int maxCount = 200;static int currentCount = 0;QtHelper::appendMsg(ui->txtData, type, data, maxCount, currentCount, clear, pause);
}void frmServer::sendData(const QString &host, int port, const QString &data)
{if (AppConfig::FilterHost != "0.0.0.0" && AppConfig::FilterHost != host) {return;}this->appendMsg(0, data);
}void frmServer::receiveData(const QString &host, int port, const QString &data)
{if (AppConfig::FilterHost != "0.0.0.0" && AppConfig::FilterHost != host) {return;}this->appendMsg(1, data);
}void frmServer::receiveInfo(const QString &host, int port, const QString &info)
{if (AppConfig::FilterHost != "0.0.0.0" && AppConfig::FilterHost != host) {return;}this->appendMsg(2, info);ui->txtData->append("\n");
}void frmServer::deviceChanged(const QString &deviceId, bool online)
{QList<GB28181Device> devices = server->getDevices();GB28181Device device = GB28181Helper::getDevice(deviceId, devices);QString text = deviceId + " [" + device.deviceName + "]";int count = ui->treeWidget->topLevelItemCount();for (int i = 0; i < count; ++i) {QTreeWidgetItem *item = ui->treeWidget->topLevelItem(i);if (item->data(0, Qt::UserRole).toString() == deviceId) {item->setText(0, text);item->setDisabled(!online);return;}}//不存在则添加顶层节点QTreeWidgetItem *item = new QTreeWidgetItem;item->setText(0, text);item->setData(0, Qt::UserRole, deviceId);ui->treeWidget->insertTopLevelItem(0, item);//添加到下拉框QString ip = device.deviceIp;if (ui->cboxDevice->findText(ip) < 0) {ui->cboxDevice->addItem(ip);}
}void frmServer::channelChanged(const QString &deviceId)
{//每次都清空通道再重新添加QList<GB28181Device> devices = server->getDevices();int count = ui->treeWidget->topLevelItemCount();for (int i = 0; i < count; ++i) {QTreeWidgetItem *item = ui->treeWidget->topLevelItem(i);if (item->data(0, Qt::UserRole).toString() != deviceId) {continue;}qDeleteAll(item->takeChildren());QStringList ids, names;GB28181Helper::getChannelInfo(deviceId, devices, ids, names);for (int j = 0; j < ids.count(); ++j) {QTreeWidgetItem *child = new QTreeWidgetItem(item);child->setText(0, ids.at(j) + " [" + names.at(j) + "]");child->setData(0, Qt::UserRole, ids.at(j));}break;}//展开所有节点ui->treeWidget->expandAll();//自动调整列宽ui->treeWidget->resizeColumnToContents(0);
}void frmServer::on_btnStart_clicked()
{this->appendMsg(0, "", true);if (ui->btnStart->text() == "启动服务") {server = new GB28181Server;connect(server, SIGNAL(sendData(QString, int, QString)), this, SLOT(sendData(QString, int, QString)));connect(server, SIGNAL(receiveData(QString, int, QString)), this, SLOT(receiveData(QString, int, QString)));connect(server, SIGNAL(receiveInfo(QString, int, QString)), this, SLOT(receiveInfo(QString, int, QString)));connect(server, SIGNAL(deviceChanged(QString, bool)), this, SLOT(deviceChanged(QString, bool)));connect(server, SIGNAL(channelChanged(QString)), this, SLOT(channelChanged(QString)));GB28181ServerPara para;para.serverId = AppConfig::ServerId;para.serverArea = AppConfig::ServerArea;para.serverHost = AppConfig::ServerHost;para.serverIp = AppConfig::ServerIp;para.serverPort = AppConfig::ServerPort;para.serverPwd = AppConfig::ServerPwd;server->setServerPara(para);ui->btnStart->setText("停止服务");ui->tabPreview->setServer(server);ui->widgetControl->setEnabled(true);ui->widgetControl->setServer(server);} else {server->stop();server->deleteLater();server = NULL;ui->treeWidget->clear();ui->treeWidget->resizeColumnToContents(0);ui->btnStart->setText("启动服务");ui->widgetControl->setEnabled(false);ui->widgetControl->setId("", "");GB28181Server::port = 6900;}AppConfig::ServerStart = (ui->btnStart->text() == "停止服务");AppConfig::writeConfig();
}void frmServer::on_btnConfig_clicked()
{static frmConfig *config = new frmConfig;config->show();config->activateWindow();
}void frmServer::selectVideo(const QString &deviceId, const QString &channelId)
{//先取消所有选中QTreeWidgetItemIterator it(ui->treeWidget);while (*it) {(*it)->setSelected(false);++it;}//视频通道按下自动选中设备数通道节点int count = ui->treeWidget->topLevelItemCount();for (int i = 0; i < count; ++i) {QTreeWidgetItem *item = ui->treeWidget->topLevelItem(i);if (item->data(0, Qt::UserRole).toString() != deviceId) {continue;}for (int j = 0; j < item->childCount(); ++j) {QTreeWidgetItem *itemChild = item->child(j);if (itemChild->data(0, Qt::UserRole).toString() == channelId) {itemChild->setSelected(true);on_treeWidget_itemClicked(itemChild, 0);break;}}}
}void frmServer::getId(QTreeWidgetItem *item, QString &deviceId, QString &channelId)
{if (item->parent()) {deviceId = item->parent()->data(0, Qt::UserRole).toString();channelId = item->data(0, Qt::UserRole).toString();} else {deviceId = item->data(0, Qt::UserRole).toString();//自动取第一个子节点if (item->childCount() > 0) {channelId = item->child(0)->data(0, Qt::UserRole).toString();}}
}void frmServer::on_treeWidget_itemClicked(QTreeWidgetItem *item, int)
{QString deviceId, channelId;this->getId(item, deviceId, channelId);ui->widgetControl->setId(deviceId, channelId);
}void frmServer::on_treeWidget_itemDoubleClicked(QTreeWidgetItem *item, int)
{QString deviceId, channelId;this->getId(item, deviceId, channelId);ui->widgetControl->setId(deviceId, channelId);ui->tabPreview->openVideo(deviceId, channelId);
}
四、相关地址
- 国内站点:https://gitee.com/feiyangqingyun
- 国际站点:https://github.com/feiyangqingyun
- 个人作品:https://blog.csdn.net/feiyangqingyun/article/details/97565652
- 文件地址:https://pan.baidu.com/s/1d7TH_GEYl5nOecuNlWJJ7g 提取码:01jf 文件名:bin_video_gb28181。
五、功能特点
- 支持设备注册、注销、用户认证、获取设备状态和信息等。
- 视频点播,可以分别点播主码流和子码流,内置rtp解包线程,解包后发给视频播放组件解码播放。
- 每个设备每个通道支持点播多个视频,通过ssrc区分,支持公用端口和不同端口。
- 云台控制,各个方位移动,镜头放大缩小,光圈放大缩小,镜头聚焦放焦。
- 纯Qt底层代码实现,udp通信交互,原创代码解析,不依赖任何第三方。
- 代码量少,gb28181交互部分共几千行代码,注释详细,接口友好,使用极其简单,提供非常详细的使用示例。
- 支持所有Qt版本和编译器以及操作系统,包括但不限于win、linux、mac、android、嵌入式linux、国产os等。