从零开发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_, ¤t_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)