From fba3e0da68cfbfeccbe9320147c7e051b9cdf86e Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sun, 9 Aug 2015 15:59:07 +0300 Subject: [PATCH] added RTSP support --- src/config.py | 65 ++++++++++++++--- src/handlers.py | 11 ++- src/utils.py | 175 +++++++++++++++++++++++++++++++++++++++++----- src/v4l2ctl.py | 17 +---- static/js/main.js | 1 + 5 files changed, 230 insertions(+), 39 deletions(-) diff --git a/src/config.py b/src/config.py index a786efa..5c7d91c 100644 --- a/src/config.py +++ b/src/config.py @@ -57,7 +57,7 @@ _KNOWN_MOTION_OPTIONS = set([ 'snapshot_filename', 'snapshot_interval', 'stream_auth_method', 'stream_authentication', 'stream_localhost', 'stream_maxrate', 'stream_motion', 'stream_port', 'stream_quality', 'target_dir', 'text_changes', 'text_double', 'text_left', 'text_right', 'threshold', 'videodevice', 'width', 'webcam_localhost', 'webcam_port', 'webcam_maxrate', 'webcam_quality', 'webcam_motion', 'ffmpeg_cap_new', 'output_normal', 'output_motion', 'jpeg_filename', 'output_all', 'gap', 'locate', - 'netcam_url', 'netcam_userpass', 'netcam_http', 'netcam_tolerant_check', 'netcam_keepalive' + 'netcam_url', 'netcam_userpass', 'netcam_http', 'netcam_tolerant_check', 'netcam_keepalive', 'rtsp_uses_tcp' ]) @@ -503,8 +503,17 @@ def add_camera(device_details): elif proto == 'netcam': camera_config['netcam_url'] = device_details['url'] camera_config['text_double'] = True + if device_details['username']: camera_config['netcam_userpass'] = device_details['username'] + ':' + device_details['password'] + + if device_details.get('camera_index') == 'udp': + camera_config['rtsp_uses_tcp'] = False + + if camera_config['netcam_url'].startswith('rtsp'): + camera_config['width'] = 640 + camera_config['height'] = 480 + _set_default_motion_camera(camera_id, camera_config) else: # assuming mjpeg @@ -721,8 +730,18 @@ def motion_camera_ui_to_dict(ui, old_config=None): # leave netcam_userpass unchanged data['netcam_keepalive'] = True data['netcam_tolerant_check'] = True - - threshold = int(float(ui['frame_change_threshold']) * 640 * 480 / 100) + + if data.get('netcam_url', old_config.get('netcam_url', '')).startswith('rtsp'): + # motion uses the configured width and height for RTSP cameras + width = int(ui['resolution'].split('x')[0]) + height = int(ui['resolution'].split('x')[1]) + data['width'] = width + data['height'] = height + + threshold = int(float(ui['frame_change_threshold']) * width * height / 100) + + else: # width & height are not available for other netcams + threshold = int(float(ui['frame_change_threshold']) * 640 * 480 / 100) data['threshold'] = threshold @@ -963,10 +982,19 @@ def motion_camera_dict_to_ui(data): ui['device_url'] = data['netcam_url'] ui['proto'] = 'netcam' - # width & height are not available for netcams, - # we have no other choice but use something like 640x480 as reference - threshold = data['threshold'] * 100.0 / (640 * 480) - + # resolutions + if data['netcam_url'].startswith('rtsp'): + # motion uses the configured width and height for RTSP cameras + resolutions = utils.COMMON_RESOLUTIONS + ui['available_resolutions'] = [(str(w) + 'x' + str(h)) for (w, h) in resolutions] + ui['resolution'] = str(data['width']) + 'x' + str(data['height']) + + threshold = data['threshold'] * 100.0 / (data['width'] * data['height']) + + else: # width & height are not available for other netcams + # we have no other choice but use something like 640x480 as reference + threshold = data['threshold'] * 100.0 / (640 * 480) + else: # assuming v4l2 ui['device_url'] = data['videodevice'] ui['proto'] = 'v4l2' @@ -1303,7 +1331,7 @@ def is_old_motion(): if version.startswith('trunkREV'): # e.g. trunkREV599 version = int(version[8:]) - return version < _LAST_OLD_CONFIG_VERSIONS[0] + return version <= _LAST_OLD_CONFIG_VERSIONS[0] elif version.count('Git'): # e.g. Unofficial-Git-a5b5f13 return False # all git versions are assumed to be new @@ -1315,6 +1343,27 @@ def is_old_motion(): return False +def motion_rtsp_support(): + import motionctl + + try: + binary, version = motionctl.find_motion() # @UnusedVariable + + if version.startswith('trunkREV'): # e.g. trunkREV599 + version = int(version[8:]) + if version > _LAST_OLD_CONFIG_VERSIONS[0]: + return ['tcp'] + + elif version.count('Git'): # e.g. Unofficial-Git-a5b5f13 + return ['tcp', 'udp'] # all git versions are assumed to support both transport protocols + + else: # stable release, should be in the format x.y.z + return [] + + except: + return [] + + def invalidate(): global _main_config_cache global _camera_config_cache diff --git a/src/handlers.py b/src/handlers.py index 5921ac7..120d481 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -497,6 +497,8 @@ class ConfigHandler(BaseHandler): remote.list(self.get_data(), on_response) elif proto == 'netcam': + scheme = self.get_data().get('scheme', 'http') + def on_response(cameras=None, error=None): if error: self.finish_json({'error': error}) @@ -504,8 +506,15 @@ class ConfigHandler(BaseHandler): else: self.finish_json({'cameras': cameras}) - utils.test_mjpeg_url(self.get_data(), auth_modes=['basic'], allow_jpeg=True, callback=on_response) + if scheme in ['http', 'https']: + utils.test_mjpeg_url(self.get_data(), auth_modes=['basic'], allow_jpeg=True, callback=on_response) + + elif config.motion_rtsp_support() and scheme == 'rtsp': + utils.test_rtsp_url(self.get_data(), callback=on_response) + else: + on_response(error='protocol %s not supported' % scheme) + elif proto == 'mjpeg': def on_response(cameras=None, error=None): if error: diff --git a/src/utils.py b/src/utils.py index 32eb0c0..d318937 100644 --- a/src/utils.py +++ b/src/utils.py @@ -21,11 +21,13 @@ import hashlib import logging import os import re +import socket import time import urllib import urlparse from tornado.httpclient import AsyncHTTPClient, HTTPRequest +from tornado.iostream import IOStream import settings @@ -40,6 +42,22 @@ except: _SIGNATURE_REGEX = re.compile('[^a-zA-Z0-9/?_.=&{}\[\]":, _-]') +COMMON_RESOLUTIONS = [ + (320, 240), + (640, 480), + (800, 480), + (1024, 576), + (1024, 768), + (1280, 720), + (1280, 800), + (1280, 960), + (1280, 1024), + (1440, 960), + (1440, 1024), + (1600, 1200) +] + + def pretty_date_time(date_time, tzinfo=None, short=False): if date_time is None: return '('+ _('never') + ')' @@ -329,6 +347,22 @@ def test_mjpeg_url(data, auth_modes, allow_jpeg, callback): called = [False] status_2xx = [False] + def do_request(on_response): + if data['username']: + auth = auth_modes[0] + + else: + auth = 'no' + + logging.debug('testing (m)jpg netcam at %s using %s authentication' % (url, auth)) + + request = HTTPRequest(url, auth_username=username, auth_password=password, auth_mode=auth_modes.pop(0), + connect_timeout=settings.REMOTE_REQUEST_TIMEOUT, request_timeout=settings.REMOTE_REQUEST_TIMEOUT, + header_callback=on_header) + + http_client = AsyncHTTPClient(force_instance=True) + http_client.fetch(request, on_response) + def on_header(header): header = header.lower() if header.startswith('content-type') and status_2xx[0]: @@ -350,22 +384,6 @@ def test_mjpeg_url(data, auth_modes, allow_jpeg, callback): if m and int(m.group(1)) / 100 == 2: status_2xx[0] = True - def do_request(on_response): - if data['username']: - auth = auth_modes[0] - - else: - auth = 'no' - - logging.debug('testing netcam at %s using %s authentication' % (url, auth)) - - request = HTTPRequest(url, auth_username=username, auth_password=password, auth_mode=auth_modes.pop(0), - connect_timeout=settings.REMOTE_REQUEST_TIMEOUT, request_timeout=settings.REMOTE_REQUEST_TIMEOUT, - header_callback=on_header) - - http_client = AsyncHTTPClient(force_instance=True) - http_client.fetch(request, on_response) - def on_response(response): if not called[0]: if response.code == 401 and auth_modes and data['username']: @@ -382,6 +400,131 @@ def test_mjpeg_url(data, auth_modes, allow_jpeg, callback): do_request(on_response) +def test_rtsp_url(data, callback): + import config + + data = dict(data) + data.setdefault('scheme', 'rtsp') + data.setdefault('host', '127.0.0.1') + data['port'] = data.get('port') or '554' + data.setdefault('uri', '') + data.setdefault('username', None) + data.setdefault('password', None) + + url = '%(scheme)s://%(host)s%(port)s%(uri)s' % { + 'scheme': data['scheme'], + 'host': data['host'], + 'port': ':' + str(data['port']) if data['port'] else '', + 'uri': data['uri'] or ''} + + called = [False] + stream = None + + def connect(): + logging.debug('testing rtsp netcam at %s' % url) + + stream = IOStream(socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)) + stream.set_close_callback(on_close) + stream.connect((data['host'], int(data['port'])), on_connect) + + return stream + + def on_connect(): + if not stream: + return logging.error('failed to connect to rtsp netcam') + + logging.debug('connected to rtsp netcam') + + stream.write('\r\n'.join([ + 'OPTIONS %s RTSP/1.0' % url.encode('utf8'), + 'CSeq: 1', + 'User-Agent: motionEye', + '', + '' + ])) + + seek_rtsp() + + def seek_rtsp(): + if check_error(): + return + + stream.read_until_regex('RTSP/1.0 \d+ ', on_rtsp) + + def on_rtsp(data): + if data.endswith('200 '): + seek_server() + + else: + handle_error('rtsp netcam returned erroneous response: %s' % data) + + def seek_server(): + if check_error(): + return + + stream.read_until_regex('Server: .*', on_server) + + def on_server(data): + identifier = re.findall('Server: (.*)', data)[0].strip() + logging.debug('rtsp netcam identifier is "%s"' % identifier) + + handle_success(identifier) + + def on_close(): + if called[0]: + return + + if not check_error(): + handle_error('connection closed') + + def handle_success(identifier): + if called[0]: + return + + called[0] = True + cameras = [] + rtsp_support = config.motion_rtsp_support() + if 'udp' in rtsp_support: + cameras.append({'id': 'udp', 'name': '%s RTSP/UDP Camera' % identifier}) + + if 'tcp' in rtsp_support: + cameras.append({'id': 'tcp', 'name': '%s RTSP/TCP Camera' % identifier}) + + callback(cameras) + + def handle_error(e): + if called[0]: + return + + called[0] = True + logging.error('rtsp client error: %s' % unicode(e)) + + try: + stream.close() + + except: + pass + + callback(error=unicode(e)) + + def check_error(): + error = getattr(stream, 'error', None) + if error and getattr(error, 'errno', None) != 0: + handle_error(error.strerror) + return True + + if stream and stream.socket is None: + logging.warning('rtsp client connection is closed') + handle_error('connection closed') + stream.close() + + return True + + return False + + stream = connect() + + def compute_signature(method, uri, body, key): parts = list(urlparse.urlsplit(uri)) query = [q for q in urlparse.parse_qsl(parts[3], keep_blank_values=True) if (q[0] != '_signature')] diff --git a/src/v4l2ctl.py b/src/v4l2ctl.py index b466e2b..08dab1a 100644 --- a/src/v4l2ctl.py +++ b/src/v4l2ctl.py @@ -178,21 +178,10 @@ def list_resolutions(device): 'device': device, 'width': width, 'height': height}) if not resolutions: - logging.debug('no resolutions found for device %(device)s, adding the defaults' % {'device': device}) - + logging.debug('no resolutions found for device %(device)s, using common values' % {'device': device}) + # no resolution returned by v4l2-ctl call, add common default resolutions - resolutions.add((320, 240)) - resolutions.add((640, 480)) - resolutions.add((800, 480)) - resolutions.add((1024, 576)) - resolutions.add((1024, 768)) - resolutions.add((1280, 720)) - resolutions.add((1280, 800)) - resolutions.add((1280, 960)) - resolutions.add((1280, 1024)) - resolutions.add((1440, 960)) - resolutions.add((1440, 1024)) - resolutions.add((1600, 1200)) + resolutions += utils.COMMON_RESOLUTIONS resolutions = list(sorted(resolutions, key=lambda r: (r[0], r[1]))) _resolutions_cache[device] = resolutions diff --git a/static/js/main.js b/static/js/main.js index beabd44..af2b7e3 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -2910,6 +2910,7 @@ function runAddCameraDialog() { data.username = usernameEntry.val(); data.password = passwordEntry.val(); data.proto = 'netcam'; + data.camera_index = addCameraSelect.val(); } else if (typeSelect.val() == 'mjpeg') { data = splitCameraUrl(urlEntry.val()); -- 2.39.5