音视频那点破事Android 音视频

Android端实现Onvif IPC开发(二)——在Andro

2018-06-12  本文已影响363人  Felix_lin

Android端实现Onvif IPC开发:

《Android端实现Onvif IPC开发(一)——gSoap移植NDK尝试》
《Android端实现Onvif IPC开发(二)——在Android端搭建服务器模拟Onvif IP Camera》
《Android端实现Onvif IPC开发(三)——在Android端搭建RTSP服务器(更新中...)》
《Android端实现Onvif IPC开发(四)——Android编码ColorFormat解析及常见格式直接转换(更新中...)》
《Android端实现Onvif IPC开发(五)——H264,H265硬编码及RTP分片传输(更新中...)》

本篇内容简介:

此处主要借鉴的项目有:https://github.com/fyhertz/spydroid-ipcamera
由于搞这个项目时,参考并阅读了许多资料,可能存在相似却未声明的借鉴之处,请联系我修改或声明

本篇是上一文章移植失败采取的第二方案,通过在android搭建service,模拟成一个onvif协议对接的IPC端,在这之前,首先需要明白,onvif设备对接的流程或者说方式,接下来的文章内容也是基于下面一条流程去实现。

一、作为Server端实现被发现功能

IPC设备基于Onvif被发现,首先要明白 WS-Discovery: 动态的探测可用服务并调用之

接下来是具体实现

注意点:

二、在Android上搭建一个Server用于接收和响应Client请求####

服务端这边主要由开源框架spydroid-ipcamera改动得来,项目地址在文章头部给出,读者可以下载阅读,其实改起来很简单,下面先对该项目简单分析:
在阅读下面分析时,最好在下载该项目,进行简单阅读或对比阅读,这样会更明了,我本人在写博客时更提倡读者自己动手,而不是直接给个现成的demo
这个工程只要有大体的实现思维,改动起来其实很容易

分析spydroid-ipcamera实现:

  1. 该项目分别搭建了rstp server和一个本地的http web server,我们这一篇文章主要分析CustomHttpServer和改动这个类
    • CustomHttpServer继承TinyHttpServer类(简便封装的Server端,基于org.apache.http框架),注意此处,我们在使用的时候,往往会出现V4包版本冲突的问题,这是由于google在新版本的v4包不再使用这个框架的缘故,故此我们只要在app gradle中声明

        android {
            useLibrary 'org.apache.http.legacy'
        }
      
    • 在TinyHttpServer中,方法addRequestHandler,学过java web的都知道,此处为解析请求内容的结构体

        //在TinyHttpServer中: 
        /** 
         * You may add some HttpRequestHandler to modify the default behavior of the server.
         * @param pattern Patterns may have three formats: * or *<uri> or <uri>*
         * @param handler A HttpRequestHandler
         */ 
        protected void addRequestHandler(String pattern, HttpRequestHandler handler) {
            mRegistry.register(pattern, handler);
        }
      
        //在CustomHttpServer中:
        @Override
        public void onCreate() {
            super.onCreate();
            mDescriptionRequestHandler = new DescriptionRequestHandler();
            addRequestHandler("/spydroid.sdp*", mDescriptionRequestHandler);//.sdp请求
            addRequestHandler("/request.json*", new CustomRequestHandler());//此处为json请求,这个搞android的应该都会,不做简述
        }
      
        //.sdp请求: SDP会话描述协议:为会话通知、会话邀请和其它形式的多媒体会话初始化等目的提供了多媒体会话描述。
                    会话目录用于协助多媒体会议的通告,并为会话参与者传送相关设置信息。 SDP 即用于将这种信息传输到接收端。
                    SDP 完全是一种会话描述格式――它不属于传输协议 ――它只使用不同的适当的传输协议,包括会话通知协议 (SAP) 、会话初始协议(SIP)
                    、实时流协议 (RTSP)、 MIME 扩展协议的电子邮件以及超文本传输协议 (HTTP)。SDP 的设计宗旨是通用性,
                    它可以应用于大范围的网络环境和应用程序,而不仅仅局限于组播会话目录
      
    • 在DescriptionRequestHandler处理网络 request 和 response, request我们在android中经常写,现在让我们来写一次response把

        /** 
         * Allows to start streams (a session contains one or more streams) from the HTTP server by requesting 
         * this URL: http://ip/spydroid.sdp (the RTSP server is not needed here). 
         **/
        class DescriptionRequestHandler implements HttpRequestHandler {
      
            private final SessionInfo[] mSessionList = new SessionInfo[MAX_STREAM_NUM];
      
            private class SessionInfo {
                public Session session;
                public String uri;
                public String description;
            }
      
            public DescriptionRequestHandler() {
                for (int i=0;i<MAX_STREAM_NUM;i++) {
                    mSessionList[i] = new SessionInfo();
                }
            }
      
            public synchronized void handle(HttpRequest request, HttpResponse response, HttpContext context) throws HttpException {
                Socket socket = ((TinyHttpServer.MHttpContext)context).getSocket();
                String uri = request.getRequestLine().getUri();
                int id = 0;
                boolean stop = false;
      
                try {
      
                    // A stream id can be specified in the URI, this id is associated to a session
                    List<NameValuePair> params = URLEncodedUtils.parse(URI.create(uri),"UTF-8");
                    uri = "";
                    if (params.size()>0) {
                        for (Iterator<NameValuePair> it = params.iterator();it.hasNext();) {
                            NameValuePair param = it.next();
                            if (param.getName().equalsIgnoreCase("id")) {
                                try {   
                                    id = Integer.parseInt(param.getValue());
                                } catch (Exception ignore) {}
                            }
                            else if (param.getName().equalsIgnoreCase("stop")) {
                                stop = true;
                            }
                        }   
                    }
      
                    params.remove("id");
                    uri = "http://c?" + URLEncodedUtils.format(params, "UTF-8");
      
                    if (!uri.equals(mSessionList[id].uri)) {
      
                        mSessionList[id].uri = uri;
      
                        // Stops all streams if a Session already exists
                        if (mSessionList[id].session != null) {
                            boolean streaming = isStreaming();
                            mSessionList[id].session.syncStop();
                            if (streaming && !isStreaming()) {
                                postMessage(MESSAGE_STREAMING_STOPPED);
                            }
                            mSessionList[id].session.release();
                            mSessionList[id].session = null;
                        }
      
                        if (!stop) {
                            
                            boolean b = false;
                            if (mSessionList[id].session != null) {
                                InetAddress dest = InetAddress.getByName(mSessionList[id].session.getDestination());
                                if (!dest.isMulticastAddress()) {
                                    b = true;
                                }
                            }
                            if (mSessionList[id].session == null || b) {
                                // Parses URI and creates the Session
                                mSessionList[id].session = UriParser.parse(uri);
                                mSessions.put(mSessionList[id].session, null);
                            } 
      
                            // Sets proper origin & dest
                            mSessionList[id].session.setOrigin(socket.getLocalAddress().getHostAddress());
                            if (mSessionList[id].session.getDestination()==null) {
                                mSessionList[id].session.setDestination(socket.getInetAddress().getHostAddress());
                            }
                            
                            // Starts all streams associated to the Session
                            boolean streaming = isStreaming();
                            mSessionList[id].session.syncStart();
                            if (!streaming && isStreaming()) {
                                postMessage(MESSAGE_STREAMING_STARTED);
                            }
      
                            mSessionList[id].description = mSessionList[id].session.getSessionDescription().replace("Unnamed", "Stream-"+id);
                            Log.v(TAG, mSessionList[id].description);
                            
                        }
                    }
      
                    final int fid = id; final boolean fstop = stop;
                    response.setStatusCode(HttpStatus.SC_OK);
                    EntityTemplate body = new EntityTemplate(new ContentProducer() {
                        public void writeTo(final OutputStream outstream) throws IOException {
                            OutputStreamWriter writer = new OutputStreamWriter(outstream, "UTF-8");
                            if (!fstop) {
                                writer.write(mSessionList[fid].description);
                            } else {
                                writer.write("STOPPED");
                            }
                            writer.flush();
                        }
                    });
                    body.setContentType("application/sdp; charset=UTF-8");
                    response.setEntity(body);
      
                } catch (Exception e) {
                    mSessionList[id].uri = "";
                    response.setStatusCode(HttpStatus.SC_INTERNAL_SERVER_ERROR);
                    Log.e(TAG,e.getMessage()!=null?e.getMessage():"An unknown error occurred");
                    e.printStackTrace();
                    postError(e,ERROR_START_FAILED);
                }
      
            }
      
        }
      
    • 在这里CustomHttpServer和CustomRtspServer,都是基于android的service,运行方法也一样:

        //1. 启动服务,别忘了在清单文件中声明
        this.startService(new Intent(this,CustomHttpServer.class));
      
        //2. bindService
        bindService(new Intent(this,CustomHttpServer.class), mHttpServiceConnection, Context.BIND_AUTO_CREATE);
        
        //3. unbindService
        if (mHttpServer != null) mHttpServer.removeCallbackListener(mHttpCallbackListener);
            unbindService(mHttpServiceConnection);
      
        //// Kills HTTP server
        this.stopService(new Intent(this,CustomHttpServer.class));
        
        private ServiceConnection mHttpServiceConnection = new ServiceConnection() {
      
            @Override
            public void onServiceConnected(ComponentName name, IBinder service) {
                mHttpServer = (CustomHttpServer) ((TinyHttpServer.LocalBinder)service).getService();
                mHttpServer.addCallbackListener(mHttpCallbackListener);
                mHttpServer.start();
            }
      
            @Override
            public void onServiceDisconnected(ComponentName name) {}
      
        };
        
        //在callback中处理相应的View显示和刷新
        private TinyHttpServer.CallbackListener mHttpCallbackListener = new TinyHttpServer.CallbackListener() {
      
            @Override
            public void onError(TinyHttpServer server, Exception e, int error) {
                // We alert the user that the port is already used by another app.
                if (error == TinyHttpServer.ERROR_HTTP_BIND_FAILED ||
                        error == TinyHttpServer.ERROR_HTTPS_BIND_FAILED) {
                    String str = error==TinyHttpServer.ERROR_HTTP_BIND_FAILED?"HTTP":"HTTPS";
                    new AlertDialog.Builder(SpydroidActivity.this)
                    .setTitle(R.string.port_used)
                    .setMessage(getString(R.string.bind_failed, str))
                    .setPositiveButton("OK", new DialogInterface.OnClickListener() {
                        public void onClick(final DialogInterface dialog, final int id) {
                            startActivityForResult(new Intent(SpydroidActivity.this, OptionsActivity.class),0);
                        }
                    })
                    .show();
                }
            }
      
            @Override
            public void onMessage(TinyHttpServer server, int message) {
                if (message==CustomHttpServer.MESSAGE_STREAMING_STARTED) {
                    if (mAdapter != null && mAdapter.getHandsetFragment() != null) 
                        mAdapter.getHandsetFragment().update();
                    if (mAdapter != null && mAdapter.getPreviewFragment() != null)  
                        mAdapter.getPreviewFragment().update();
                } else if (message==CustomHttpServer.MESSAGE_STREAMING_STOPPED) {
                    if (mAdapter != null && mAdapter.getHandsetFragment() != null) 
                        mAdapter.getHandsetFragment().update();
                    if (mAdapter != null && mAdapter.getPreviewFragment() != null)  
                        mAdapter.getPreviewFragment().update();
                }
            }
      
        };
      

接下来实现我们的Onvif Server:

首先选取我们要用的,TinyHttpServer是我们需要的,参照CustomHttpServer写一个OnvifHttpServer,其中web访问我们不做,可以去掉

  1. 自定义一个OnvifHttpServer集成TinyHttpServer,对照CustomHttpServer实现相应方法,Onvif Client请求端请求的方式是post,请求方式我们就假装不知道,我们定义Request pattern为所有格式即可,即: "/*"

     @Override
      public void onCreate() {
               super.onCreate();
               mDescriptionRequestHandler = new DescriptionRequestHandler();
               mDescriptionOnvifHandler = new DescriptionOnvifHandler();
               addRequestHandler("/*", mDescriptionOnvifHandler);
               addRequestHandler("/request.json*", new CustomRequestHandler());
      }
    
  2. 实现基于Onvif的请求解析DescriptionRequestHandler,返回的格式为:"application/soap+xml; charset=UTF-8",接口有很多,要想实现标准工具播放,要实现很多很多的接口,下面简单介绍几个,感兴趣的可以自己去抓包标准设备完成:

    • GetDeviceInformation:获取设备信息

    • GetCapabilities :获取设备性能

    • GetProfiles获取设备权限

    • GetStreamUri 获取设备流媒体服务地址和相应信息

        /**
         * this URL for onvif request.
         **/
        class DescriptionOnvifHandler implements HttpRequestHandler {
                 private final IpcServerApplication mApplication;
      
                 public DescriptionOnvifHandler() {
                          mApplication = (IpcServerApplication) getApplication();
                 }
      
                 public void handle(HttpRequest request, HttpResponse response, HttpContext arg2) throws HttpException, IOException {
                          if (request.getRequestLine().getMethod().equals("POST")) {//onvif请求为post
                                   // Retrieve the POST content
                                   final String url = URLDecoder.decode(request.getRequestLine().getUri());
                                   Log.e(TAG, "DescriptionRequestHandler------------------:url " + url);
                                   HttpEntityEnclosingRequest post = (HttpEntityEnclosingRequest) request;
                                   byte[] entityContent = EntityUtils.toByteArray(post.getEntity());
                                   String content = new String(entityContent, Charset.forName("UTF-8"));
      
                                   //此处需要用到响应的你想给请求设备的信息,我作为一个全局的bean类保存,此处可以实现为序列化到本地或者sp保存都行
                                   DevicesBackBean devicesBack = mApplication.getDevicesBack();
                                   LogUtils.e(TAG, "DescriptionRequestHandler :" + devicesBack.toString());
      
                                   String backS = null;
                                   //接下来就是返回请求了,这里可以解析请求的包,即XML数据,判断相应为什么请求,这个地方就体现了,模拟Onvif ipc
                                   //的尿性之处,请求接口相当繁多复杂,蛋疼的一匹,这样实现终非正道,如果要完成一个标识的Onvif IPC端还是要采取我第
                                   //一篇文章的移植才是正轨,这里简要实现几个接口,作为参考,大家有问题可以私信我一起沟通
                                   if (content.contains("GetDeviceInformation")) {
                                            backS = Utilities.getPostString("GetDeviceInformationReturn.xml", false, getApplicationContext(), mApplication.getDevicesBack());
                                            LogUtils.e(TAG, "DescriptionRequestHandler :" + mApplication.getDevicesBack().toString());
                                   } else if (content.contains("GetProfiles")) {//需要鉴权
                                            if (needProfiles) {
                                                     boolean isAuthTrue = DigestUtils.doAuthBack(content);
                                                     if (!isAuthTrue) {
                                                              doBackFail(response);
                                                              return;
                                                     }
                                            }                                           
                                            backS = Utilities.getPostString("getProfilesReturn.xml", true, getApplicationContext(), mApplication.getDevicesBack());                                         
                                            LogUtils.e("DescriptionRequestHandler", "-------getProfilesReturn--" + backS);                                                                                                
                                   }else {
                                            doBackFail(response);
                                            return;
                                   }
                                   LogUtils.e(TAG, "getProfilesReturn backS:" + backS);
                                   // Return the response
                                   final String finalBackS = backS;
                                   ByteArrayEntity body = new ByteArrayEntity(finalBackS.getBytes());
                                   ByteArrayInputStream is = (ByteArrayInputStream) body.getContent();
                                   response.setStatusCode(HttpStatus.SC_OK);
                                   body.setContentType("application/soap+xml; charset=UTF-8");
                                   response.setEntity(body);
                          }
                 }
        }
      
  3. 下面给一个我抓包的请求:

    • getProfiles和getProfilesBack:

        <?xml version="1.0" encoding="utf-8"?>
        <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
            <s:Header>
                <Security xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
                    <UsernameToken>
                        <Username>%s</Username>
                        <Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">%s</Password>
                        <Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">%s</Nonce>
                        <Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">%s</Created>
                    </UsernameToken>
                </Security>
            </s:Header>
            <s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
                <GetProfile xmlns="http://www.onvif.org/ver10/media/wsdl">
                    <ProfileToken>%s</ProfileToken>
                </GetProfile>
            </s:Body>
        </s:Envelope>
      
        <?xml version="1.0" encoding="utf-8" ?>
        <SOAP-ENV:Envelope xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding" xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:trt="http://www.onvif.org/ver10/media/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema">
            <SOAP-ENV:Body><trt:GetProfilesResponse><trt:Profiles fixed="true" token="Profile1">
                        <tt:Name>Profile1</tt:Name>
                        <tt:VideoSourceConfiguration token="VideoSourceToken">
                            <tt:Name>VideoSourceConfig</tt:Name><tt:UseCount>1</tt:UseCount><tt:SourceToken>VideoSource_1</tt:SourceToken><tt:Bounds height="%s" width="%s" x="0" y="0"/>
                        </tt:VideoSourceConfiguration>
                        <tt:VideoEncoderConfiguration token="VideoEncoderToken_1">
                            <tt:Name>VideoEncoder_1</tt:Name><tt:UseCount>1</tt:UseCount><tt:Encoding>%s</tt:Encoding><tt:Resolution>
                                <tt:Width>%s</tt:Width><tt:Height>%s</tt:Height>
                            </tt:Resolution>
                            <tt:Quality>44.0</tt:Quality>
                            <tt:RateControl><tt:FrameRateLimit>%s</tt:FrameRateLimit><tt:EncodingInterval>1</tt:EncodingInterval><tt:BitrateLimit>%s</tt:BitrateLimit>
                            </tt:RateControl>
                            <tt:H264><tt:GovLength>100</tt:GovLength><tt:H264Profile>Baseline</tt:H264Profile>
                            </tt:H264>
                            <tt:Multicast>
                                <tt:Address>
                                    <tt:Type>IPv4</tt:Type><tt:IPv4Address>0.0.0.0</tt:IPv4Address><tt:IPv6Address />
                                </tt:Address>
                                <tt:Port>0</tt:Port>
                                <tt:TTL>0</tt:TTL>
                                <tt:AutoStart>false</tt:AutoStart>
                            </tt:Multicast>
                            <tt:SessionTimeout>PT30S</tt:SessionTimeout>
                        </tt:VideoEncoderConfiguration>
                        <tt:PTZConfiguration token="PTZToken">
                            <tt:Name>PTZ</tt:Name>
                            <tt:UseCount>1</tt:UseCount>
                            <tt:NodeToken>PTZNODETOKEN</tt:NodeToken>
                            <tt:DefaultAbsolutePantTiltPositionSpace>http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace</tt:DefaultAbsolutePantTiltPositionSpace>
                            <tt:DefaultAbsoluteZoomPositionSpace>http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace</tt:DefaultAbsoluteZoomPositionSpace>
                            <tt:DefaultRelativePanTiltTranslationSpace>http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationGenericSpace</tt:DefaultRelativePanTiltTranslationSpace>
                            <tt:DefaultRelativeZoomTranslationSpace>http://www.onvif.org/ver10/tptz/ZoomSpaces/TranslationGenericSpace</tt:DefaultRelativeZoomTranslationSpace>
                            <tt:DefaultContinuousPanTiltVelocitySpace>http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace</tt:DefaultContinuousPanTiltVelocitySpace>
                            <tt:DefaultContinuousZoomVelocitySpace>http://www.onvif.org/ver10/tptz/ZoomSpaces/VelocityGenericSpace</tt:DefaultContinuousZoomVelocitySpace>
                            <tt:DefaultPTZSpeed>
                                <tt:PanTilt space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace" x="0.100000" y="0.100000"/>
                                <tt:Zoom space="http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace" x="1.000000"/>
                            </tt:DefaultPTZSpeed>
                            <tt:DefaultPTZTimeout>PT1S</tt:DefaultPTZTimeout>
                            <tt:PanTiltLimits>
                                <tt:Range>
                                    <tt:URI>http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace</tt:URI>
                                    <tt:XRange>
                                        <tt:Min>-INF</tt:Min><tt:Max>INF</tt:Max>
                                    </tt:XRange>
                                    <tt:YRange>
                                        <tt:Min>-INF</tt:Min><tt:Max>INF</tt:Max>
                                    </tt:YRange>
                                </tt:Range>
                            </tt:PanTiltLimits>
                            <tt:ZoomLimits>
                                <tt:Range>
                                    <tt:URI>http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace</tt:URI>
                                    <tt:XRange>
                                        <tt:Min>-INF</tt:Min>
                                        <tt:Max>INF</tt:Max>
                                    </tt:XRange>
                                </tt:Range>
                            </tt:ZoomLimits>
                        </tt:PTZConfiguration>
                    </trt:Profiles>
                </trt:GetProfilesResponse>
            </SOAP-ENV:Body>
        </SOAP-ENV:Envelope>
      
  4. 返回端给出的代码只需要把相应的需要返回的信息替代到里面字符串即可

  5. 关于鉴权方面,Onvif的鉴权有2中,WS-username token和Digest,可参考文章:Onvif协议及其在Android下的实现官方格式为

     Digest=B64Encode(SHA1(B64ENCODE(Nonce)+Date+Password))
     //nonce只是一个16位随机数即可
     //Sha-1: MessageDigest md = MessageDigest.getInstance("SHA-1");
     //date:参考值:"2013-09-17T09:13:35Z";  由客户端请求给出
     //此处我的不便摘要,详细可参考文章:https://blog.csdn.net/yanjiee/article/details/18809107
     public String getPasswordEncode(String nonce, String password, String date) {  
         try {  
             MessageDigest md = MessageDigest.getInstance("SHA-1");  
             byte[] b1 = Base64.decode(nonce.getBytes(), Base64.DEFAULT);  
             byte[] b2 = date.getBytes(); // "2013-09-17T09:13:35Z";  
             byte[] b3 = password.getBytes();  
             byte[] b4 = new byte[b1.length + b2.length + b3.length];  
             md.update(b1, 0, b1.length);  
             md.update(b2, 0, b2.length);  
             md.update(b3, 0, b3.length);  
             b4 = md.digest();  
             String result = new String(Base64.encode(b4, Base64.DEFAULT));  
             return result.replace("\n", "");  
         } catch (Exception e) {  
             e.printStackTrace();  
             return "";  
         }  
     }  
       
     public String getNonce() {  
         String base = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";  
         Random random = new Random();  
         StringBuffer sb = new StringBuffer();  
         for (int i = 0; i < 24; i++) {  
             int number = random.nextInt(base.length());  
             sb.append(base.charAt(number));  
         }  
         return sb.toString();  
     }  
       
     private void createAuthString() {  
         SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'",  
                 Locale.CHINA);  
         mCreated = df.format(new Date());  
         mNonce = getNonce();  
         mAuthPwd = getPasswordEncode(mNonce, mCamera.password, mCreated);  
     }  
    

三、当当当当当!,完成上面的服务框架搭建,启动服务

可通过标准工具检查到我们的设备啦,并且还能查询到响应的信息

onvif_1.png
onvif_2.png

接下来我们要搭建RTSP服务器,即可以在标准工具中进行播放,请查看我的下一篇文章:《Android端实现Onvif IPC开发(三)——在Android端搭建RTSP服务器》

上一篇下一篇

猜你喜欢

热点阅读