一、前言說明
用純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等。