关于c++:nodejs实现国标GB28181设备接入sip服务器解决方案SkeyeVSS国标视频云平台

29次阅读

共计 9247 个字符,预计需要花费 24 分钟才能阅读完成。

GB28181 接入服务器是 SkeyeVSS 接入 GB28181 设施 / 平台的信令交互服务器,GB28181 将 SIP 定位为联网零碎的次要信令根底协定,并利用 SIP 协定的无关扩大,实现了对非会话业务的兼顾,例如,对报警业务、历史视音频回放、下载等的反对。目前有 GB28181-2011 和 GB28181-2016 两个版本。

  GB28181 接入服务器对接入零碎的 GB28181 设施的治理,全副通过一个 20 位的设施 ID 号来治理;以 SIP 协定为载体,以 REGISTER、INVITE、MESSAGE 等命令实现与 28181 设施和 GB28181 流媒体服务器的交互。随着 node.js 社区的一直壮大,借助其弱小的事件驱动和 IO 并发能力,曾经衍生出了很多很弱小的模块(包),实现各种各样的性能,使得服务端程序开发变得非常容易,习惯了 C/C++ 编程的程序员相对会感到非常诧异,因为竟然有一种语言开发能够如此高效且简略(PS: 我也就刚学习一个月 node.js 而已 - --!);而本文将要解说的是一种通过 node.js 实现接入国标设施以及平台的 sip 信令服务器的计划。

筹备工作

首先,下载 node.js 并装置,windows,linux 平台均反对; 最好有一个比拟弱小的 JS 编辑器或者 IDE,我举荐一个非常弱小且轻量级的 IDE 兼编辑神器 Visual Studio Code。
而后,相熟 npm 管理工具的命令,通过 npm 装置各个须要依赖的 node.js 模块,其中最重要的 sip 模块,通过如下命令装置:

npm install sip

node.js 领有弱小的包管理工具,能够通过如下命令搜寻咱们可能须要的 node.js 模块:

npm search xxx

如下图所示:

其余 node.js 相干学习大家感兴趣能够在网上找到非常丰盛的材料,比方举荐一本比拟好书《深入浅出 node.js》, 当然最好的倡议是:看个毛线的书,老夫都是间接撸代码!

国标接入流程

1 承受上级的注册和登记
首先,咱们须要建设一个 sip 服务来检测和承受上级设施或者平台的注册命令的解决,如下代码所示:

        sip.start(option, async request => {switch (request.method)
            {
                case common.SIP_REGISTER:
                    this.emit('register', request);  
                    break;
                case common.SIP_MESSAGE:
                    this.emit('message', request);  
                    break;
                case common.SIP_INVITE:
                    this.emit('invite', request);
                    break;
                case common.SIP_ACK:
                    this.emit('ack', request);
                    break;
                case common.SIP_BYE:
                    this.emit('bye', request);
                    break;
                default:
                    console.log('unsupported:' + request.method);
                    break;
            }
        });

而后,sip 服务接管设施端注册申请,回调函数中进行解决:

                case common.SIP_REGISTER:
                    try {const username = sip.parseUri(request.headers.to.uri).user;
                        const userinfo = config.userinfos[username];
                        const session = {realm: config.server.realm};

                        if (!userinfo) 
                        {sip.send(digest.challenge(session, sip.makeResponse(request, 401, common.messages[401])));
                            this.session_.set(username,session);
                            return;
                        }
                        else 
                        {if(!this.session_.has(username)){this.session_.set(username,session);
                            }
                            userinfo.session = userinfo.session || this.session_.get(username);
                            if (!digest.authenticateRequest(userinfo.session, request, { user: username, password: userinfo.password})) 
                            {sip.send(digest.challenge(userinfo.session, sip.makeResponse(request, 401, common.messages[401])));
                                this.session_.set(username,userinfo.session);

                                return;
                            } else 
                            {this.session_.delete(username);
                                if(request.headers.expires === '0'){this.emit('unregister', request);
                                }
                                else{this.emit('register', request);
                                }
                                let response = sip.makeResponse(request, 200, common.messages[200]);
                                sip.send(response);
                            }
                        }
                    } catch (e) {
                        // 输入到控制台
                        console.log(e);
                    }
                    break;

如上代码所示,依据国标 gb28181 规范解决逻辑如下:
1) SIP 代理向 SIP 服务器发送 REGISTER 申请,申请中未蕴含 Authorization 字段;SIP 服务器向 SIP 代理发送响应 401,并在响应的音讯头 WWW_Authenticate 字段中给
出适宜 SIP 代理的认证体制和参数;
2) SIP 代理从新向 SIP 服务器发送 REGISTER 申请,在申请的 Authorization 字段给出信赖书,蕴含认证信息;SIP 服务器对申请进行验证,如果查看出 SIP 代理身份非法,向 SIP 代理发送胜利响应 200 OK,如果身份不非法则发送拒绝服务应答。
值得注意的是,有些国标设施接入并不遵循以上注册逻辑,这种多见于老旧的国标设施或者平台,其注册甚至都不会携带认证信息,而是通过双向注册实现验证。

2 查问设施目录列表信息
依据国标协定规范,查问设施目录,
MESSAGE 音讯头 Content-type 头域为 Content-type: Application/MANSCDP+xml
查问命令采纳 MANSCDP 协定格局定义,具体国标协定文档。
查问申请命令应包含命令类型(CmdType)、命令序列号(SN)、设施编码(DeviceID),采纳 RFC 3428 的 MESSAGE 办法的音讯体携带。相干设施在收到 MESSAGE 音讯后,应立即返回应答,应答均无音讯体;一个查问目录 XML 如下示例:

<?xml version="1.0"?>
<Query> 
    <SN>1</SN> 
    <DeviceID>64010000001110000001</DeviceID> 
</Query> 

查问目录函数 GetCatalog 函数如下代码所示:

    async getCatalog(serial) {const device = await devices.getDevice(serial);
        if (common.isEmpty(device)) {return {};
        }  
        const json = {
            Query: {
                CmdType: common.CMD_CATALOG,
                SN: common.sn(),
                DeviceID: serial
            }
        };
    
        const builder = new xml2js.Builder();
        const content = builder.buildObject(json);
    
        const options = {
            method: common.SIP_MESSAGE,
            serial: serial,
            contentType: common.CONTENT_MANSCDP,
            content: content,
            host: device.host,
            port: device.port,
            callId: common.callId(),
            fromTag: common.tag()};

        const response = await uas.send(options);
        if (common.isEmpty(response)) {return {};
        } else {}}

查问设施目录应答 ”MESSAGE” 办法音讯,回调函数解决如下:

        uas.on('message', async ctx => {
            const request = ctx.request;
            if (request.content.length === 0) {return;}
            const vias = request.headers.via;
            const via = vias[0];
            const json = await this.parseXml(request.content);
            if(json.hasOwnProperty(common.TYPE_RESPONSE)) { //Response
                switch (json.Response.CmdType) {
                    case common.CMD_CATALOG:
                        ctx.send(200);
                        if (request.headers['content-length'] === 0) {return ;}
                        let items = json.Response.DeviceList.Item;
                        let allCount = json.Response.SumNum;
                        let itemCount = json.Response.DeviceList.$.Num;
                        let channels = [];
                        let deviceInfo = {
                            host: via.params.received,
                            port: via.params.rport,
                            count: 0,
                            channels: []};
                        if(devices.hasDevice(json.Response.DeviceID)){deviceInfo = devices.getDevice(json.Response.DeviceID);
                            channels = deviceInfo.channels;
                        }
                        else{return ;}
                        deviceInfo.count = allCount;
                        let id = channels.length;                               
                        if(itemCount>1) {for (let item of items) {
                                id = channels.length;
                                try {
                                    let channel = {
                                        channel: id,
                                        type: 1,
                                        name: item.Name,
                                        serial: item.DeviceID,
                                        status: item.Status==='ON'?1:0,
                                        ability: '10000000',
                                        snapurl: '',
                                        model: item.Model,// 设施型号
                                        brand: 2,
                                        version: 'v1.0'
                                    };

                                    if(channels.length>0){for(let ch of channels){if(ch.serial === item.DeviceID){
                                                id = ch.channel-1;
                                                break;
                                            }
                                        }
                                    }
                                    channel.channel = id+1;
                                    channels[id] = channel;                                  
                                } catch (e) {}}
                        }
                        else {
                            try {
                                const channel = {
                                    channel: id,
                                    type: 1,
                                    name: items.Name,
                                    serial: items.DeviceID,
                                    status: items.Status==='ON'?1:0,
                                    ability: '10000000',
                                    snapurl: '',
                                    model: items.Model,// 设施型号
                                    brand: 2,
                                    version: 'v1.0'
                                };
                                if(channels.length>0){for(let ch of channels){if(ch.serial === items.DeviceID){
                                            id = ch.channel-1;
                                            break;
                                        }
                                    }
                                }
                                channel.channel = id+1;
                                channels[id] = channel; 
                            } catch (e) {}}
                        deviceInfo.channels = channels;
                        devices.addDevice(json.Response.DeviceID, deviceInfo);
                       //TODO: Add device to redis
                        {
                            try {
                                const infoString = {
                                    host: deviceInfo.host,
                                    port: deviceInfo.port,
                                    serial: json.Response.DeviceID,
                                    type: 2,//1= 摄像机 2=NVR
                                    count: deviceInfo.count,
                                    channels: deviceInfo.channels                      
                                };
                                let info = infoString;
                                info.serverId = common.serial;

                                await redis.set(`${common.DEVICE}:${json.Response.DeviceID}`, JSON.stringify(info), 'EX', common.DEVICE_EXPIRE);
                            } catch (e) {console.log(e);
                            }
                        }
                        //info.channels = channels;                     
                        break;
                    default:
                        break;
                }
            }
            ctx.send(200);
        });

须要留神几点:
(1) 在公网利用时,设施注册上来的 sip 信令交互中填写的 IP 和端口很有可能是内网的端口,而理论的传输 IP 和端口通过 via 的 param 中获取:host: via.params.received, port: via.params.rport;
(2) 设施目录查问时,如果摄像机个数比拟多,则可能分屡次回调 Response,这时候须要做相应解决,如上代码所示;

3 实时流媒体点播
实时流媒体点播即拉流,gb28181 协定定义的拉流逻辑如下图所示:

从上图咱们能够看出,拉流逻辑须要通过一个流媒体服务器进行直达,所以拉流逻辑须要流媒体服务器的配合能力实现,所以,残缺的拉流逻辑我会在另一篇博客《node.js 实现国标 GB28181 流媒体点播服务解决方案》中进行具体解说。

4 设施管制
源设施向指标设施发送设施管制命令,管制命令的类型包含球机 / 云台管制、近程启动、录像管制、
报警布防 / 撤防、报警复位等,设施管制采纳 RFC 3428 中的 MESSAGE 办法实现。源设施包含 SIP 客户端,指标设施包含 SIP 设施或者网关。源设施向指标设施发送球机 / 云台管制命令、近程启动命令后,指标设施不发送应答命令。(摘录自《GB+28181 国家标准《平安防备视频监控联网零碎信息传输、替换、控制技术要求》》)
本文次要解说云台管制的流程实现,其余设施管制命令相似。
一个云台管制 XML 音讯体示例:

<?xml version="1.0"?> <Control> 
<CmdType>DeviceControl</CmdType> 
<SN>11</SN> 
<DeviceID>64010000041310000345</DeviceID> 
<PTZCmd>A50F4D1000001021</PTZCmd> 
<Info> 
<ControlPriority>5</ControlPriority> 
</Info> 
</Control> 

从上音讯体中,咱们能够看出次要须要填写的字段就是 PTZCmd 这个 8 个字节的头缓冲区。
具体解释如下:(内容摘录自《GB+28181 国家标准《平安防备视频监控联网零碎信息传输、替换、控制技术要求》》)
(1)表 L.1 指令格局

字节 字节 1 字节 2 字节 3 字节 4 字节 5 字节 6 字节 7 字节 8
含意 A5H 组合码 1 地址 指令 数据 1 数据 2 组合码 2 校验码

各字节定义如下:字节 1:指令的首字节为 A5H;字节 2:组合码 1,高 4 位是版本信息,低 4 位是校验位。本规范的版本号是 1.0,版本信息为 0H;校验位 =(字节 1 的高 4 位 + 字节 1 的低 4 位 + 字节 2 的高 4 位)%16;字节 3:地址的低 8 位;字节 4:指令码;字节 5、6:数据 1 和数据 2;字节 7:组合码 2,高 4 位是数据 3,低 4 位是地址的高 4 位;在后续叙述中,没有特地指明的高 4 位,示意该 4 位与所指定的性能无关;字节 8:校验码,为后面的第 1—7 字节的算术和的低 8 位,即算术和对 256 取模后的后果;字节 8 =(字节 1 + 字节 2 + 字节 3 + 字节 4 + 字节 5 + 字节 6 + 字节 7)%256。地址范畴 000H—FFFH(即 0—4095),其中 000H 地址作为播送地址。
(2)L.2 PTZ 指令
PTZ 指令见表 L.2。表 L.2 PTZ 指令 由慢到快为 00H-FFH。注 4:字节 7 的高 4 位为变焦速度,速度范畴由慢到快为 0H-FH;低 4 位为地址的高 4 位。
| 字节 | 位 |
|:- |:——|

· Bit7 Bit6 Bit5 Bit4 Bit3 Bit2 Bit1 Bit0
字节 4 0 0 镜头变倍(Zoom) 镜头变倍(Zoom) 云台垂直方向管制(Tilt) 云台垂直方向管制(Tilt) 云台程度方向管制 (Pan)
放大(OUT) 放大(IN) 上(Up) 下(Down) 左(Left) 右(Right)
字节 5 程度管制速度相对值
字节 6 垂直管制速度相对值
字节 7 变倍管制速度相对值 地址高 4 位

注 1:字节 4 中的 Bit5、Bit4 别离管制镜头变倍的放大和放大,字节 4 中的 B it3、Bit2、B it1、Bit0 位别离管制云台上、下、左、右方向的转动,相应 Bit 地位 1 时,启动云台向相应方向转动,相应 Bit 位清 0 时,进行云台相应方向的转动。云台的转动方向以监视器显示图像的挪动方向为准。注 2:Bit5 和 Bit4 不应同时为 1,Bit3 和 Bit2 不应同时为 1;Bit1 和 Bit0 不应同时为 1。镜头变倍指令、云台高低指令、云台左右指令三者能够组合。注 3:字节 5 管制程度方向速度,速度范畴由慢到快为 00H-FFH;字节 6 管制垂直方向速度,速度范畴

PTZ 指令举例见表 L.3。
表 L.3 PTZ 指令举例

序号 字节 4 字节 5 字节 6 字节 7 高 4 位 性能形容
1 20H XX XX 0H-FH 镜头以字节 7 高 4 位的数值变倍放大
2 10H XX XX 0H-FH 镜头以字节 7 高 4 位的数值变倍放大
3 08H 00H-FFH XX X 云台以字节 6 给出的速度值向上方向静止
4 04H 00H-FFH XX X 云台以字节 6 给出的速度值向下方向静止
5 02H XX 00H-FFH X 云台以字节 5 给出的速度值向左方向静止
6 01H XX 00H-FFH X 云台以字节 5 给出的速度值向右方向静止
7 00H XX XX X PTZ 的所有操作均进行
8 29H 00H-FFH 00H-FFH 0H-FH 这是一个 PTZ 组合指令的示例:云台以字节 5 给出的速度值向右方向静止,同时以字节 6 给出的速度值向上方向静止,实际上是斜向右上方向运行;与此同时,镜头以字节 7 高 4 位的数值变倍放大

通过以上国标协定的具体诠释,咱们得以实现云台管制的命令封装,申请函数如下:

    async ptzControl(serial, code, callId, command, speed){const devices = require('gateway/devices');
        const device = await devices.getDevice(serial);
        if (common.isEmpty(device)) {return {};
        }
        //define PTZCmd  header 8 字节
        let cmd = Buffer.alloc(8);
        cmd[0] = 0xA5;// 首字节以 05H 结尾
        cmd[1] = 0x0F;// 组合码,高 4 位为版本信息 v1.0, 版本信息 0H,低四位为校验码
                      //  校验码 = (cmd[0]的高 4 位 +cmd[0]的低 4 位 +cmd[1]的高 4 位)%16
        cmd[2] = 0x01;// 地址的低 8 位???什么地址,地址范畴 000h ~ FFFh(0~4095), 其中 000h 为播送地址
        cmd[3] = common.ptzCMD[command];    // 指令码
        let ptzSpeed = parseInt(speed);
        if(ptzSpeed>0xff)
            ptzSpeed = 0xff;
        cmd[4] = ptzSpeed;       // 数据 1, 程度管制速度、聚焦速度
        cmd[5] = ptzSpeed;       // 数据 2,垂直管制速度、光圈速度
        cmd[6] = 0x00;           // 高 4 位为数据 3 = 变倍管制速度,低 4 位为地址高 4 位
        if(command === 9||command === 10){
            let zoomSpeed = speed;
            if(zoomSpeed > 0x0F){zoomSpeed = 0x0F;}
            cmd[6] = zoomSpeed<<4|0;
        }
        else if(command === 16||command === 17||command === 18) {
            //16: 0x81, // 设置预置位
            //17: 0x82, // 调用预置位
            //18: 0x83  // 删除预置位          
        }
        cmd[7] = (cmd[0]+cmd[1]+cmd[2]+cmd[3]+cmd[4]+cmd[5]+cmd[6])%256;
        var cmdString = common.Bytes2HexString(cmd);
        //generate XML
        const xmlJson = {
            Control: {
                CmdType: 'DeviceControl',
                SN: command,
                DeviceID: code,
                PTZCmd: cmdString,//'A50F000800C80084'//cmdString,
                
                Info: {ControlPriority: 5}
            }
        };

        const builder = new xml2js.Builder();  // JSON->xml
        //var parser = new xml2js.Parser();   //xml -> json
        const xml =  builder.buildObject(xmlJson);
        console.log('xml ='+xml);

        const options = {
            method: common.SIP_MESSAGE,
            serial: serial,
            contentType: common.CONTENT_MANSCDP,
            content: xml,
            host: device.host,
            port: device.port,
            callId: callId,
            fromTag: common.tag()};

        const response = await uas.send(options);
        // if (response.status === 200) {//     await uas.sendAck(response);
        // }
    
        return response;          
    }

留神:本文中所波及的 GB28181 协定最低兼容 GB28181 协定 2011 版本,向上兼容 2016 版本。

SkeyeVSS 技术交换 QQ 群:102644504

正文完
 0