全网首创/纯Qt/C++实现国标GB28181服务/实时视频/云台控制/预置位/录像回放和下载/事件订阅/语音对讲
一、前言说明
用纯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等。