当前位置: 首页 > article >正文

全网首创/纯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.csdn.net/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.csdn.net/feiyangqingyun/article/details/97565652
  4. 文件地址:https://pan.baidu.com/s/1d7TH_GEYl5nOecuNlWJJ7g 提取码:01jf 文件名:bin_video_gb28181。

五、功能特点

  1. 支持设备注册、注销、用户认证、获取设备状态和信息等。
  2. 视频点播,可以分别点播主码流和子码流,内置rtp解包线程,解包后发给视频播放组件解码播放。
  3. 每个设备每个通道支持点播多个视频,通过ssrc区分,支持公用端口和不同端口。
  4. 云台控制,各个方位移动,镜头放大缩小,光圈放大缩小,镜头聚焦放焦。
  5. 纯Qt底层代码实现,udp通信交互,原创代码解析,不依赖任何第三方。
  6. 代码量少,gb28181交互部分共几千行代码,注释详细,接口友好,使用极其简单,提供非常详细的使用示例。
  7. 支持所有Qt版本和编译器以及操作系统,包括但不限于win、linux、mac、android、嵌入式linux、国产os等。

http://www.kler.cn/a/591835.html

相关文章:

  • 飞腾2000+/64核加固服务器
  • ruoyi-vue部署
  • 虚幻基础:组件组件通信
  • PreparedStatement:Java 数据库操作的安全与高效之道
  • STM32---FreeRTOS任务通知
  • SpringBoot实现发邮件功能+邮件内容带模版
  • 深入浅出DBSCAN:基于密度的聚类算法
  • 华为营销流程落地方案:MTC=MTL+LTC
  • 删除排序链表中的重复元素(js实现,LeetCode:83)
  • C++ —— 时间操作 chrono 库
  • DeepLearning:卷积神经网络基础补充
  • python实现接口自动化
  • Paper Reading: AnomalyGPT:利用大型视觉-语言模型检测工业异常 (AAAI 2024 Oral)
  • 20. Excel 自动化:Excel 对象模型
  • Springboot中的@ConditionalOnBean注解:使用指南与最佳实践
  • 4.2 Reactive 对象的深度类型约束方案
  • linux 命令 cp
  • Pycharm接入DeepSeek,提升自动化脚本的写作效率
  • 基于YOLOv8深度学习的PCB缺陷检测识别系统【python源码+GUI界面+数据集+训练代码】
  • C# BindingFlags 使用详解