全网首创/纯Qt/C++实现国标GB28181服务/实时视频/云台控制/预置位/录像回放和下载/事件订阅/语音对讲

一、前言说明

用纯Qt来实现这个GB28181的想法很久了,具体可以追溯到2014年,一晃十年都过去了,总算是整体的框架和逻辑都打通了,总归还是杂七杂八的事情多,无法静下心来研究具体的协议,最开始初步了解协议后发现比onvif要复杂不少,索性先搁置一旁,所以先把onvif协议打通了,onvif协议好是好,但是一般在局域网内使用,外网访问几乎没有办法,而GB28181就是为了解决很多痛点定义的一套视频监控规范,毕竟现在满大街都是监控,各个部门机构都要外网远程取流,这就必须上国标,这其实是网络通信的弊端,服务端在没有收到过客户端的消息的时候,是无法得知客户端的具体通信地址,也就无法通信,需要先客户端主动给服务器发过消息才行。

关于sip协议的第三方库已经很多了,最终还是决定采用Qt底层的udp通信协议来解析,一方面可以大大加深协议的理解,提供友好的使用接口,不用大费周章的去编译各种第三方库,一方面考虑到后期的拓展,必须从底层手撸,一定要把兼容性易用性摆在第一位,这个最重要的点,也只有解决了客户的痛点,才能更好的卖钱。

1.1 个人理解

  1. gb2818协议是基于sip协议的一套协议框架,而sip是和http协议类似的一套基于udp/tcp载体的协议,最终底层通过udp/tcp收发数据。sip是一套多媒体通信框架,而gb28181就是在这套框架上,定义了具体的通信内容,也就是收发数据填充的内容。
  2. sip协议收发通常用的第三方开源库有osip/exosip,exosip依赖osip,是对osip的二次封装,带了具体传输层,osip相当于用来构建要收发的数据,本身没有收发功能。还有个更强大开源库pjsip,带了rtp解析。具体可参考 https://blog.youkuaiyun.com/weixin_43147845/article/details/144219082
  3. sip协议说复杂也复杂,说简单也简单,复杂就是涉及到的具体协议规约特别庞大,和http协议是并列的一种机制。简单就是如果只是少量的通信,可以直接用udp/tcp通信收发解析,要发送的数据每一行都回车换行,最后一起发出去即可。就是发送和解析不大方便,需要去寻找和取出关键字再处理,而开源库会给你处理好对应的数据结构,比如解析后有枚举字段直接判断。
  4. onvif是国际标准协议,gb28181是国家标准协议,各有优缺点,onvif通常是通过搜索到设备再去和设备通信,而gb28181刚好相反,是让设备主动连服务器,带上校验等参数,连上后,服务器再去和设备通信。这样相当于可以跨网了,而onvif通常只能局域网。gb28181还有个优势就是组网,可以层层级联,非常适合国内各种大监控系统的建设。
  5. onvif客户端是先udp组播获取到设备,然后再发送http请求到不同的地址来交互,请求中可以获取到视频流rtsp地址,然后自己用ffmpeg等框架打开这个地址播放就行。而gb28181服务端是先udp/tcp监听端口,对连上的设备进行sip协议格式的数据内容的交互,发送请求播放指令后,开启发送rtp数据包,再用ffmpeg等框架解析这个数据包就行。
  6. gb28181注册的时候Expires为有效期时间,一般是3600秒,如果是0则表示注销。

1.2 要点总结

  1. gb28181协议一般会选择udp通信,默认也是udp,早期国标设备都是只支持udp。
  2. 服务端开启端口监听,设备端填写好对应参数后,会尝试往对应端口发数据进行连接。
  3. 设备端间隔(心跳间隔默认是60s)发送REGISTER信令,服务端收到后,分析数据中是否带了鉴权信息(也就是用户认证相关信息),没有带的话则应答Unauthorized,带了的话,可以取出认证的信息,和要求的参数对比,比如国标服务端编号、认证密码、域编码信息,不一致则应答信息错误,叫客户端重新发。都没问题则表示认证通过。
  4. 认证后服务端发送MESSAGE信令,带上xml数据,获取设备信息,收到设备信息后,再去查阅目录也就是获取通道列表。
  5. 设备端在注册成功后,每隔一段时间发送一次心跳信息。方便服务端判断是否离线。
  6. 设备端一旦注册成功,在有效期(一般是3600s)内,不会再去注册,默认就是认为已经注册成功。所以有时候服务端这边开启服务后,未必先收到REGISTER注册指令,而是可能先收到的心跳指令,所以服务端这边要做个特殊处理,收到心跳后,先判断该设备在系统中是否已经存在,不存在则先获取设备信息,再去获取通信列表。
  7. 服务端支持多个设备注册,通过设备编号区分,严格要求同一个系统中设备编号不能重复,否则容易错乱。
  8. 每个设备都可以有多个视频通道,一般摄像头IPC只有1个通道,录像机NVR有多个通道。如果是国标级联,相当于把服务端当做一台NVR设备,每个设备的通道都转换成唯一标识的通道。
  9. 点播视频和云台控制等,都有个前提是要先获取到对应的通道列表,因为下发的数据中就要指定是哪个通道。
  10. 点播视频是服务端向设备端通过发送INVITE信令,带上sdp数据(具体sdp格式规范在gb28181-2016文档的第100页),sdp数据中包含了通道编号、音视频格式、音视频数据如何交互等信息。
  11. 服务端点播视频前,先要打开一个空闲的端口,这个端口号在sdp数据中带上,设备端收到点播指令后,会将音视频数据发给这个指定的端口,收到这些数据后再去用ffmpeg解码播放即可。sip这边只负责信令交互,并不负责音视频数据的通信。
  12. 关闭视频很关键,因为可能开了多个点播窗口,所以需要在点播视频后应答的ACK指令数据中,记住当时信令中的from/to/callid数据,在关闭视频的时候用播放时对应的这几个数据发给设备端,才能真正停止。
  13. 一个设备可能有多个通道,一个通道可能存在多个点播流,每个流都对应唯一的端口,所以需要有个队列记住这些点播流对应的ssrc/from/to/callid数据,可以指定关闭某一路流。
  14. 点播流需要对应端口接收流,一般这个端口需要动态分配,也可以不同流公用一个端口,公用端口不用担心数据会冲突,里面都是rtp的数据包,通过ssrc区分是哪一个流的数据包,这个ssrc是由点播发起者下发的,在sip指令中附加在subject属性上,sdp中有个y属性专门放这个ssrc字符串。在端口数量允许的情况下,一般建议每一路流都不同的端口,方便区分管理。
  15. 点播流的过程,一般第一步是先打开监听端口成功后,然后才将这个端口通过sip指令发给设备,因为端口有可能被占用,所以只有当打开监听端口成功的时候,再去点播流,这样才是通的,不然也是白搭。
  16. 语音对讲和点播视频流程不一样,是反着来的,先服务端发送语音广播指令Broadcast到设备,设备返回是否支持语音对讲,如果支持,会主动发送INVITE信令,带上sdp数据,服务端搜到这个sdp数据后解析,然后服务端主动往设备对应的端口发送带了语音数据的RTP数据包,设备端的声音会通过之前的音视频流传输过来。
  17. 云台控制和预置位相关处理就简单一些,因为都是单向操作。通过MESSAGE信令带上xml数据,数据中包含了要执行的通道编号和动作,这个动作的数据,是一个标准的固定长度8字节,16进制字符串数据格式,比如A5 0F 01 00 00 00 00 00,将要执行的动作替换对应的数据位即可,停止云台也是一个单独的动作。
  18. ffmpeg中并不能直接解码RTP数据包,需要解包后才是PS流才可以正确的解码,一般会用第三方开源库jrtp去实现解包,当然他也支持封包,发送语音数据的时候也要用到,jrtp直接就是带了网络通信,比如监听UDP端口收数据。
  19. 在信令交互过程中,可以多一些无关的数据,但是不能少一些必要的字段数据,比如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);
}

四、相关地址

  1. 国内站点:https://gitee.com/feiyangqingyun
  2. 国际站点:https://github.com/feiyangqingyun
  3. 个人作品:https://blog.youkuaiyun.com/feiyangqingyun/article/details/97565652
  4. 文件地址:https://pan.baidu.com/s/1d7TH_GEYl5nOecuNlWJJ7g 提取码:01jf 文件名:bin_video_gb28181。

五、功能特点

  1. 支持设备注册、注销、心跳、校时、注册认证、注销认证等。
  2. 设备上线后可以手动获取设备状态、设备信息、配置信息、预置位信息等。
  3. 设备上线后自动获取设备通道信息,包括中文通道名称。识别到通道上线离线变化,会重新获取该设备的所有通道信息。
  4. 支持视频点播,可以分别点播主码流和子码流,内置rtp解包线程,解包后发给视频播放组件解码播放。
  5. 每个设备每个通道支持点播多个视频,通过ssrc区分,支持共用端口和不同端口收流。
  6. 支持对某个设备下面所有通道、某个通道、某个通道对应的某个流分别关闭。
  7. 支持录像文件查询和回放,回放控制支持暂停播放、继续播放、倍速播放、切换播放进度。
  8. 支持录像文件下载,支持倍速比如8倍速下载,可同时多线程批量下载。
  9. 回放和下载同时支持IPC和NVR,比如摄像头自带的SD存储卡录像文件回放,NVR上的硬盘录像文件回放。
  10. 支持云台控制,向上、向下、向左、向右、左上、右上、左下、右下方位移动,镜头放大缩小,光圈放大缩小,镜头聚焦放焦。
  11. 支持预置位信息的查询、调用、添加、修改、删除等操作。
  12. 自动目录订阅功能,通道上线下线都有对应的信号通知。
  13. 内置定时读取通道信息机制,以保证通道信息是最新的,比如有些NVR是不断更新的通道信息。
  14. 内置订阅警情和位置移动功能,订阅后各种警情事件比如运动目标检测报警、入侵检测报警、徘徊检测报警等自动上报。
  15. 支持语音对讲功能,可以直接在视频窗体的悬浮条上单击语音对讲按钮,再次单击关闭对讲,对讲期间悬浮条常驻显示。
  16. 支持设备布防撤防,布防后警情信息会主动上报。
  17. 国标服务同时支持udp和tcp方式,可选只监听一种或者两种都监听,tcp方式自动处理粘包问题。
  18. 国标拉流同时支持udp、tcp被动、tcp主动三种方式,每个通道都可以自由选择何种拉流方式。
  19. 内置拉流端口池,每次拉流从中取出一个,关闭流自动回收端口号,重复利用。
  20. 收流端口自动纠错,自动跳过被占用的端口,不会出现端口占用导致收流失败的情况。
  21. 支持三种取流方式自动检测离线重连,检测到离线后,自动重启点播拉流整个流程。
  22. 录像文件回放,上一个完成后自动切换到下一个继续回放,直到所有回放完成。支持高达8倍速回放。
  23. 视频播放自适应硬解码,极低资源占用,实时性极好,带悬浮条显示视频流信息,可以直接在悬浮条单击按钮保存录像文件到本地。
  24. 支持几千路国标消息交互并发,实时视频流支持64路同时显示,可以拓展更多路数。
  25. 支持阿里云等云服务器,可以分别设置内网监听地址和外网访问地址,一般云服务器上是监听地址用内网,对外访问用外网地址。
  26. 支持视频分发,也就是推流,视频通道打开后可以自动推流到流媒体服务器,其他需要的地方拉流即可,支持rtsp、rtmp、hls、webrtc等方式拉流。
  27. 实时预览和录像回放都支持推流,推流支持叠加文字和图片水印以及各种ffmpeg支持的滤镜效果,支持多个水印同时叠加。
  28. 同时支持gb28181-2011、gb28181-2016、gb28181-2022以及后续可能的所有协议版本。
  29. SIP解析和交互采用纯Qt底层代码实现,udp/tcp通信交互,祖传原创代码解析,不依赖任何第三方。
  30. 代码量少,gb28181交互部分共几千行代码,注释详细,接口友好,使用极其简单,提供非常详细的使用示例。
  31. 支持海康、大华、宇视、华为、天地伟业等所有国标设备,包括一些没有ssrc的设备。
  32. 支持所有Qt版本和编译器以及操作系统,包括但不限于win、linux、mac、android、嵌入式linux、树莓派香橙派、国产os等。
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值