Fork me on GitHub

从零开发SIP客户端(Windows)踩坑实录

           目前手头上开发一个SIP客户端的项目。只有服务器是已经开发好的,客户端啥资料都没有。从零开发。

           搜索了几天后,确定使用PJSIP作为SIP协议栈框架。microsip是一个根据pjsip开发的一个很好的demo。

 

一、DEMO相关

           1、下载PJSIP并使用VS2013编译。

                 下载地址:http://www.pjsip.org/

                 1)用VS2013打开pjproject-vs8.sln,升级项目。

                 2)对全部项目修改输出文件

 

              3)新建config_site.h文件

               

 

 

              内容为:(暂时屏蔽掉视频相关的,后续补充)

             

 

 

           

            跑通pjlib_test 与 pjsipua 。注意pjlib_test中 errno_test()可能不过,建议屏蔽。

 

 

  

        至此,pjsip项目先放着备用。

 

       2、下载microsip并跑通。

             1)添加账号

      

 

 

 

           2)拨号(另外台电脑配置好,并关闭防火墙)

 

 

 

 

 

           3、把microsip的业务逻辑移植到pjsip中。

                1)microsip添加账号源码分析

                        i) 根据用户的输入对Accont对象赋值

                      

 

 

 

                        ii) 删除 PJAccountDelete

                            ① 内部先取消订阅 PresenceUnsubsribe

                           

if (pjsua_var.state == PJSUA_STATE_RUNNING) {
        pjsua_buddy_id ids[PJSUA_MAX_BUDDIES];
        unsigned count = PJSUA_MAX_BUDDIES;
        pjsua_enum_buddies(ids, &count);
        for (unsigned i = 0; i < count; i++)
        {
            pjsua_buddy_del(ids[i]);
        }
    }

                          ② 删除账号

                           

if (pjsua_acc_is_valid(account)) {
        pjsua_acc_del(account);
        account = PJSUA_INVALID_ID;
    }

                          ③添加账号 

                         

pj_status_t status;
        pjsua_acc_config acc_cfg;
        PJAccountConfig(&acc_cfg, &accountSettings.accountLocal);  //默认组装

        CString localURI;
        if (!accountSettings.accountLocal.displayName.IsEmpty()) {
            localURI = _T("\"") + accountSettings.accountLocal.displayName + _T("\" ");
        }
        CString domain;
        if (!accountSettings.accountLocal.domain.IsEmpty()) {
            domain = accountSettings.accountLocal.domain;
        }
        else {
            pjsua_transport_data *t = &pjsua_var.tpdata[0];
            domain = MSIP::PjToStr(&t->local_name.host);
        }
        if (!accountSettings.accountLocal.username.IsEmpty()) {
            localURI.AppendFormat(_T("<sip:%s@%s>"), accountSettings.accountLocal.username, domain);
        }
        else {
            localURI.AppendFormat(_T("<sip:%s>"), domain);
        }

        acc_cfg.id = MSIP::StrToPjStr(localURI);  //id计算比较复杂
        acc_cfg.priority--;
        pjsua_acc_add(&acc_cfg, PJ_TRUE, &account_local);
        acc_cfg.priority++;

                 简化下 (cred_info 与 id稍微复杂点。)                  

        pjsua_acc_config_default(acc_cfg);
    // global
    acc_cfg->ka_interval = account->keepAlive;
        acc_cfg->reg_timeout = account->registerRefresh;
        acc_cfg->use_srtp = PJMEDIA_SRTP_DISABLED;
        acc_cfg->ice_cfg_use = PJSUA_ICE_CONFIG_USE_CUSTOM;
        acc_cfg->transport_id = transport_tcp;
        acc_cfg->cred_count = 1;
    acc_cfg->cred_info[0].username = MSIP::StrToPjStraccount->authID : (isLocal ? account->username : get_account_username()));
    acc_cfg->cred_info[0].realm = pj_str("*");
    acc_cfg->cred_info[0].scheme = pj_str("Digest");
    acc_cfg->cred_info[0].data_type = PJSIP_CRED_DATA_PLAIN_PASSWD;
    acc_cfg->cred_info[0].data = MSIP::StrToPjStr((isLocal ? account->password : get_account_password()));
        acc_cfg->id = <sip:%s@%s> ;//username @ domain

       pjsua_acc_add(&acc_cfg,PJ_TRUE,& id);

 

               2) 尝试在PJSIPdemo中添加账号(假设服务器地址为10.10.10.10:5060  本地的ip为192.168.10.13  用户名为8001 密码为123456)

               

char id[80], registrar[80], realm[80], uname[80], passwd[30];
    pjsua_acc_config acc_cfg;
    pj_status_t status;

    if (!simple_input("Your SIP URL:", id, sizeof(id)))   //<sip:8001@192.168.10.13>
    return;
    if (!simple_input("URL of the registrar:", registrar, sizeof(registrar)))   //sip:10.10.10.10:5060;transport=tcp
    return;
    if (!simple_input("Auth Realm:", realm, sizeof(realm)))  //*
    return;
    if (!simple_input("Auth Username:", uname, sizeof(uname))) //8001
    return; 
    if (!simple_input("Auth Password:", passwd, sizeof(passwd))) //123456
    return;

    pjsua_acc_config_default(&acc_cfg);
    acc_cfg.id = pj_str(id);
    acc_cfg.reg_uri = pj_str(registrar);  //这个上面的代码没有。搜了下在另外的地方有。贴下面。
    acc_cfg.cred_count = 1;
    acc_cfg.cred_info[0].scheme = pj_str("Digest");
    acc_cfg.cred_info[0].realm = pj_str(realm);
    acc_cfg.cred_info[0].username = pj_str(uname);
    acc_cfg.cred_info[0].data_type = 0;
    acc_cfg.cred_info[0].data = pj_str(passwd);

    acc_cfg.rtp_cfg = *rtp_cfg;
    app_config_init_video(&acc_cfg);

    status = pjsua_acc_add(&acc_cfg, PJ_TRUE, NULL);
    if (status != PJ_SUCCESS) {
    pjsua_perror(THIS_FILE, "Error adding new account", status);
    }

       

        CString regURI;
        regURI.Format(_T("sip:%s"), get_account_server());
        AddTransportSuffix(regURI,&accountSettings.account);
        acc_cfg.reg_uri = MSIP::StrToPjStr(regURI);

                demo上跑一遍试试

                 

 

                 添加完按回车

 

                  其中[0]和[1]是本地的UDP和TCP默认链接,[2]是我们添加的账号。奇怪的是Online status是Offline,暂时不管。

              3)打电话分析

                   核心代码为:

                   pjsua_call_make_call(current_acc, &tmp, &call_opt, NULL,&msg_data_, &current_call);

                   其中第一个参数是发起电话的人,使用了current_acc,而不是我们刚才的添加的人。我们回头看看添加的人的代码。

                   status = pjsua_acc_add(&acc_cfg, PJ_TRUE, NULL);最后个参数返回添加的账户的id,我们居然没有保存。。

                   我们得想办法添加的本地账户就是tcp的带注册地址的账户。

                   i) 设置no_udp为true,可以少一个没用的udp账户。

                           

 

                   ii) 修改tcp的账户信息,以上面添加的字段填充。

                   

 

                    iii)make call!

                       发现需要添加联系人信息(+b命令),从microsip中可以看出,格式为<sip:name@concactIp;transport=tcp>

                       

 

                      输入m

                      在输入 1 试试。

                      哇,成功了!

               

            4)、接听电话

                      输入命令 a  

                      然后输入 200 (状态码)

                      关键代码:

                      pjsua_call_answer2()

             5)、挂断电话

                      输入命令 h

                      关键代码:

                      pjsua_call_hangup()

            

二、SIP与PJSIP原理及细节。

             这样,最基本的流程走通了一遍。接下来,补习下sip与pjsip的原理与知识点。

             网上的资料已经很多,这里不重复贴了,本帖子主要还是介绍如何使用PJSIP并移植到自己的项目。

            ① 关于SIP协议:首先要了解是个应用层协议(基于IP协议),和http协议类似,有请求和应答。有请求头和内容。

        (图片来自网络)

             推荐的一些博客:(看博客的时候结合程序的日志输出更清楚,程序的日志输出已经包含了报文详情)

                                          https://support.huawei.com/enterprise/zh/doc/EDOC1000082155?section=j006

                                          https://www.cnblogs.com/xiaxveliang/p/12434170.html

                                          https://blog.csdn.net/braveyly/article/details/6420282   

             到这里,相信大家已经对SIP有了不错的了解,不妨默写下注册流程与呼叫流程,写不出来的话,建议再看一篇博客。

             ②关于PJSIP:已经对SIP协议的报文进行了封装。框架结构图与运行设计图(图片来自网络)。

 

 

 

 

              推荐的一些博客:

                         https://www.cnblogs.com/rayfloyd/p/7206815.html

                         https://www.geek-share.com/detail/2792503092.html

             实测发现 “pjsua是PJSIP开源库中能够使用到的最高层次抽象API” 并且可以运行。让我萌生一个大胆的想法,直接基于(或模仿)pjsua进行改造,做成一个独立的sip电话exe插件供业务层调用不是美滋滋。

            优点如下:

            1、不再需要搞清楚PJSIP的库依赖关系的细节,无需搞清楚需要添加哪些头文件与lib库。

            2、没有编译问题的困扰(PJSIPUA已经可以成功编译,并有exe实例产生,甚至可以稳定的打断点DEBUG)。

            3、不会干扰主程序,万一PJSIP异常或崩溃,不会影响到主程序的异常。

            4、资源利用更充分。看了PJSIP的架构,发现需要占用很大的资源与线程,独立开发,不会影响主程序的性能,自己的性能也更健康

 

 三、架构设计与常用流程图。

            1、主程序与插件的通信方式:  多进程的通信方式最好使用成熟的技术或框架(如共享内存或命名管道等),项目比较赶,这块先这里简单做,使用PostMessage方式。

                  后续有时间再优化成更可靠的通信方式。不用COPYDATA,是因为该方法是同步阻塞的,怕影响主程序的卡顿。

                                                               主程序启动插件的时候,把自己的窗口句柄在命令行里传给插件。当插件启动完时,给主程序发送自己的窗口句柄,后续就能PostMessage通信了。

            2、插件的接口设计:

                  插件尽可能抽象到用户能直接使用的方式,并根据用户的实际需求增改接口。

                  本期我们先实现最简单的用户需求     1)、拨打电话   2)、接听电话   3)、挂断电话 

                  另外插件能通话的前提是注册成功,注册的操作可以放到程序启动时。

 

            3、流程图示例

                 ①、用户登录流程

                

 

               1、用户登录

               2、客户端请求登录

               3、服务器返回成功,并且带了sip协议的用户信息

               4、客户端启动插件,通过命令行传递窗口句柄和sip用户信息

               5、插件启动成功,给客户端回传自己的窗口句柄,默认状态离线

               6、插件向sip服务器请求注册

               7、sip服务器返回401 unauthorized和鉴权信息

               8、 插件添加完鉴权信息后再次sip服务器请求注册

               9、sip服务器返回注册成功

               10、插件向客户端发送消息在线

 

              ②、拨打电话流程

              

                                        1、用户发起呼叫

                                        2、客户端向服务器校验个人权限(如是否已经欠费)

                                        3、服务器返回成功

                                        4、客户端通过插件发起呼叫 (由于postmessage跨进程只能传递32位整形参数,这里如果是标准的<sip:xxx@xxx>的话,没办法传递,待修正)

                                        5-9、sip协议标准流程

                                        10、插件提醒客户端振铃

                                        11-14、sip协议标准流程

                                        15、插件提醒客户端接通

                                        16、由插件与对方建立的连接通话。      

 

                  ③、接听流程

                   

 

 

                                     1-6、sip协议标准流程

                                     7、插件通知客户端有电话来了,并且包含用户信息。(由于postmessage跨进程只能传递32位整形参数,这里如果是标准的<sip:xxx@xxx>的话,没办法传递,待修正)

                                     8、用户用客户端接听,客户端通过插件发起接听

                                     9-12、sip协议标准流程

                                    13、插件提醒客户端连接已经建立

                                    14、由插件与对方建立的连接通话。

                 ④、主动拒接流程

 

 

                                     1-6、sip协议标准流程

                                     7、插件通知客户端有电话来了,并且包含用户信息。(由于postmessage跨进程只能传递32位整形参数,这里如果是标准的<sip:xxx@xxx>的话,没办法传递,待修正)

                                     8、用户用客户端拒绝,客户端通过插件发起忙碌

                                     9-12、sip协议标准流程

                                    13、插件向客户端发送在线(只有收到ACK后才能拨打下一通电话,有必要实现一套状态机。)

                      

                 ⑤、对方拒接流程

 

 

 

                                        1、用户发起呼叫

                                        2、客户端向服务器校验个人权限(如是否已经欠费)

                                        3、服务器返回成功

                                        4、客户端通过插件发起呼叫 (由于postmessage跨进程只能传递32位整形参数,这里如果是标准的<sip:xxx@xxx>的话,没办法传递,待修正)

                                        5-9、sip协议标准流程

                                        10、插件提醒客户端振铃

                                        11-12、对方忙碌

                                        13、插件通知客户端对方忙碌

                                        14-15、SIP回复

                                        16、插件向客户端发送在线(只有收到ACK后才能拨打下一通电话,有必要实现一套状态机。) 

                 ⑥、主动挂断流程

                                        

 

                                              1、用户发起挂断

                                              2、客户端向插件发起挂断

                                              3-6、SIP挂断协议

                                              7、插件向客户端发送在线

 

                 4、SIP状态机设计(主要用来显示界面,不同的状态的UI应该不同的)

                                             

 

 

 

                   5、守护进程及插件的自动重连设计

                        1)、考虑到插件进程的异常崩溃(或意外退出)等,导致后续的通话不可用问题。

                        2)、考虑插件因为某时段的网络问题而离线,导致后续的通话不可用问题。

                        3)、主程序退出后,插件的生命周期管理问题。

                        4)、主程序意外退出,插件的脱离管理问题。

                        结合上面几种情况。得设计一套可靠的方案。

 

                        有必要设计下主进程的自动检测及插件的自动重连方案。

                       ①、插件的自动重连与主程序校测流程

 

 

 

                   ②、主程序的插件自动检测与重启流程

              

 

                      ③、主程序退出时需要把插件也退出(当然不做这个逻辑,插件也会自动退出,= 。=)。

 

 

 四、 开始撸代码。(多进程通信最终还是采用WM_COPYDATA,为了防止阻塞,异步处理)

         接口设计代码。        

         

/** @file siptalk_data.h
* @brief 维护对Sip插件的功能调用与通信(设计见https://www.cnblogs.com/xuhuajie/p/13445294.html)
* @copyright (c) 2020-2023, Netease Inc. All rights reserved
* @author Xuhuajie
* @date 2020/8/13
*/

#pragma once
//命令名字,需要与bizDta.h同步
#define SIP_CMD_ANSWER         "answer_call"
#define SIP_CMD_CALL           "make_call"
#define SIP_CMD_HANGUP         "hangup_call"
#define SIP_CMD_CANCEL         "cancel_call"
#define SIP_CMD_INCOMING       "state_incoming"

#define SIP_PLUGIN_NAME         L"sipPlugin.exe"

#define HEART_GAP              5000

namespace nim_comp
{
    //Sip的状态集合
    enum SipStatus
    {
        sip_status_default,
        sip_status_offline,
        sip_status_online,
        sip_status_calling,
        sip_status_ringing,
        sip_status_answering,
        sip_status_connected,
        sip_status_hunguping,
        sip_status_disconnected,  //断开连接了,和在线的状态有点类似,用两种状态可以更好的满足业务
    };

    //需要与bizDta.h同步
    enum SipErrorCode
    {
        sip_error_ok = 0,
        sip_error_unknown,
        sip_error_server_error,
        sip_error_calling_error,
        sip_error_hangup_error,
        sip_error_answer_error,
        sip_error_cancel,
        sip_error_timeout,
        sip_error_notfound,
    };

    //Sip的简单通知消息体集合,从WM_APP开始 需要与bizDta.h同步
    //wParam 错误码 
    enum SipMessage
    {
        SIP_MSG_ONLINE = WM_APP + 1,    //lParam携带sipWnd
        SIP_MSG_OFFLINE,
        SIP_MSG_CALLING,
        SIP_MSG_ANSERING,
        SIP_MSG_HANGUPING,
        SIP_MSG_CONNECTED,
        SIP_MSG_INCOMING,
        SIP_MSG_DISCONNECT,

        //心跳
        SIP_MSG_HEART, 
    };

//     //Sip的状态业务回调
    class ISipStatusNotify
    {
    public:
        virtual ~ISipStatusNotify(){}

        /**
        * @brief 状态回调
        * @param[out] status   状态
        * @param[out] call_id  保留,多路通话时的时候需要这个字段,目前不需要
        * @param[out] id       分配的id,或电话号码(如8005 或 13000000001)
        * @param[out] code     错误码    
        * @return 无
        * @remark: 当code非sip_error_ok时,表明此时的status中出现了错误。有些错误情况建议维持原来的状态
        (如挂断失败,此时如果已经是在线,则在线,连接中,则继续连接中比较好)。
        (如拨打超时失败,则需要从calling转换成online,业务层好好斟酌取舍)
        (当状态是断开连接时,code适合提示用户为啥出错)
        */
        virtual void OnSipStatusNotify(SipStatus status, std::string call_id, std::string id, SipErrorCode code) = 0;
     };

    //Sip的命令集合
    enum SipCmd
    {
        sip_cmd_register,
        sip_cmd_call,
        sip_cmd_answer,
        sip_cmd_handup,
    };

    //Sip的协议类型
    enum SipProtocol
    {
        sip_p_udp, 
        sip_p_tcp,
        sip_p_tls,   //暂不支持
    };

    struct restartParam
    {
        HWND hwnd_;
        std::string server_;
        std::string id_;
        std::string user_;
        std::string pwd_;
        std::string stun_;
        SipProtocol p_;
    };

}
/** @file siptalk_manager.h
* @brief 维护对Sip插件的功能调用与通信(设计见https://www.cnblogs.com/xuhuajie/p/13445294.html)
* @copyright (c) 2020-2023, Netease Inc. All rights reserved
* @author Xuhuajie
* @date 2020/8/13
*/

#pragma once
#include "siptalk_data.h"

namespace nim_comp
{
    class SipTalkManager
    {
    public:
        SINGLETON_DEFINE(SipTalkManager);
    public:
        SipTalkManager();
        ~SipTalkManager();

        /**
        * @brief 管理器初始化(新建一个无界面窗口,用于进程间通信)
        * @return true 初始化成功 false 初始化失败
        */
        bool InitEnvironment();

        /**
        * @brief 界面层增加状态通知回调(先设计成只需要一个的,目前不支持同时拨打多路的需求)
        */
        void AddListener(ISipStatusNotify* notify);

        /**
        * @brief 界面层移除状态通知回调
        */
        void RemoveListener(ISipStatusNotify* notify);

        /**
        * @brief 启动插件(插件会自动注册)
        * @param[in] server sip服务器地址(需要带端口)
        * @param[in] id     分配的id,或电话号码(如8005 或 13000000001)
        * @param[in] user   鉴权的用户名,空则使用id
        * @param[in] pwd    鉴权的密码
        * @param[in] p      协议类型,默认TCP
        * @return true 启动成功 false 启动失败
        */
        bool StartSipPlugin(const std::string& server,const std::string &id,const std::string &user,
            const std::string& pwd,const std::string& stun,SipProtocol p=sip_p_tcp);
        /**
        * @brief 关闭插件
        */
        void StopSipPlugin();
        /**
        * @brief 呼叫电话
        * @param[in] id     对方分配的id,或电话号码(如8005 或 13000000001)
        * @return true 呼叫成功 false 呼叫失败
        * @remark : 协议格式:  { "func":"make_call","param":"id" }
        */
        bool MakeCall(const std::string& id);


        /**
        * @brief 应答电话
        * @param[in] call_id    会话id,目前不需要,多路通话的时候才有用,保留
        * @return true 应答成功 false 应答失败
        * @remark : 协议格式:  { "func":"answer_call","param":"call_id" }
        */
        bool AnswerCall(const std::string& call_id);

        /**
        * @brief 拒绝电话
        * @param[in] call_id    会话id,目前不需要,多路通话的时候才有用,保留
        * @return true 应答成功 false 应答失败
        * @remark : 协议格式:  { "func":"cancel_call","param":"call_id" }
        */
        bool CancelCall(const std::string& call_id);

        /**
        * @brief 挂断电话
        * @param[in] call_id    会话id,目前不需要,多路通话的时候才有用,保留
        * @return true 挂断成功 false 挂断失败
        * @remark : 协议格式:  { "func":"hangup_call","param":"call_id" }
        */
        bool HangUpCall(const std::string& call_id);


        /**
        * @brief 设置插件窗口句柄,用于通信
        * @param[in] hWnd   通信的插件窗口句柄
        * @return 无
        */
        void SetSipWnd(HWND hWnd);
        /**
        * @brief 检测插件是否存活,是否需要重启
        */
        void CheckSipPluginAlive();
        /**
        * @brief 插件传递的状态信息
        */
        void SetStatus(SipStatus status, std::string id, SipErrorCode code);

    private:
        /**
        * @brief 启动插件(插件会自动注册)
        * @return true 启动成功 false 启动失败
        */
        bool startSipPlugin();
        /**
        * @brief 进程间通信发送命令
        */
        void SendCmd(const std::string& funcname,const std::string& cmdparam);

        /**
        * @brief 自带的消息处理函数
        */
        static LRESULT CALLBACK    WndProc(HWND, UINT, WPARAM, LPARAM);
    private:
        SipStatus    sip_status_;
        restartParam param_;  //重启参数
        HWND         sip_wnd_; //插件的窗口句柄,方便进程间通信
        time_t       lasttimestamp_;  
        bool         bInit_;

        ISipStatusNotify* notify_;
    };
}

        

 

         详细见:https://github.com/xuhuajie-NetEase/SipTalk

 

         如何使用?

        1、pjsua里面的代码复制到对应的pjsip的项目里面。

        2、添加新增的文件。

        3、修改编译器选项

   

 

 

       

 

 

                 

 

五、成果物

       

 

 

 

 

 

 六、其他

通话过程中发送与接听按键信息。

        使用DTMF。

        1)接收:

        call_on_dtmf_callback2 里面包含了DTMF的细节。

        2)发送,稍微复杂一点,有3种格式,要看服务器支持哪(几)种。

        in-band 、RFC2833、sip-info 

        其中RFC2833最简单,pjsua_call_dial_dtmf 搞定。

        sip-info其次,(发送一个sip的INFO协议)

       

char body[80];
pjsua_msg_data msg_data_;

pjsua_msg_data_init(&msg_data_);
msg_data_.content_type = pj_str("application/dtmf-relay");

pj_ansi_snprintf(body, sizeof(body),
"Signal=%c\r\n"
"Duration=160",
digits.ptr[i]);
msg_data_.msg_body = pj_str(body);

status = pjsua_call_send_request(current_call, &SIP_INFO,
&msg_data_);

        in-band目前我也没看懂。

      

        新增:静音、dtmf、麦克风检测(已提交github)

 

posted @ 2020-08-06 11:40  烟波--钓徒  阅读(5619)  评论(0编辑  收藏  举报