From: Calin Crisan Date: Wed, 19 Aug 2015 14:52:43 +0000 (+0300) Subject: initial work on migrating to setuptools X-Git-Url: http://www.vanbest.org/gitweb/?a=commitdiff_plain;h=cd268e4099bb4c21fe207eda5a9ac91eb5516da4;p=motioneye-debian initial work on migrating to setuptools --- diff --git a/.gitignore b/.gitignore index 3be82e4..806f37a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,7 @@ *.conf *.json .settings -settings.py conf run media -log \ No newline at end of file +log diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1c2510d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md + diff --git a/README.md b/README.md index dd5865d..de116c1 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,6 @@ * ffmpeg * v4l-utils -On a debian-based system you could run (as root): - - apt-get install motion ffmpeg v4l-utils python-pip - pip install python-imaging jinja2 pycurl tornado - -## Browser Compatibility ## - -motionEye works fine with most modern browsers, including IE9+. -Being designed with responsiveness in mind, it will also work nicely on mobile devices and tablets. - ## Installation ## 1. download the latest version from [bitbucket](https://bitbucket.org/ccrisan/motioneye/downloads) (use the *Tags* tab). @@ -42,4 +32,4 @@ Being designed with responsiveness in mind, it will also work nicely on mobile d ./motioneye.py - 5. point your favourite browser to \ No newline at end of file + 5. point your favourite browser to diff --git a/eventrelay.py b/eventrelay.py deleted file mode 100755 index 42c02f3..0000000 --- a/eventrelay.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import errno -import json -import logging -import os.path -import sys -import urllib - -sys.path.append(os.path.join(os.path.dirname(sys.argv[0]),'src')) - -import settings -import utils - -from motioneye import _configure_settings, _configure_logging - - -_configure_settings() -_configure_logging(module='eventrelay') - - -def print_usage(): - print 'Usage: eventrelay.py ' - - -def get_admin_credentials(): - # this shortcut function is a bit faster than using the config module functions - config_file_path = os.path.join(settings.CONF_PATH, 'motion.conf') - - logging.debug('reading main config from file %(path)s...' % {'path': config_file_path}) - - lines = None - try: - file = open(config_file_path, 'r') - - except IOError as e: - if e.errno == errno.ENOENT: # file does not exist - logging.info('main config file %(path)s does not exist, using default values' % {'path': config_file_path}) - - lines = [] - - else: - logging.error('could not open main config file %(path)s: %(msg)s' % { - 'path': config_file_path, 'msg': unicode(e)}) - - raise - - if lines is None: - try: - lines = [l[:-1] for l in file.readlines()] - - except Exception as e: - logging.error('could not read main config file %(path)s: %(msg)s' % { - 'path': config_file_path, 'msg': unicode(e)}) - - raise - - finally: - file.close() - - admin_username = 'admin' - admin_password = '' - for line in lines: - line = line.strip() - if not line.startswith('#'): - continue - - line = line[1:].strip() - if line.startswith('@admin_username'): - parts = line.split(' ', 1) - admin_username = parts[1] if len(parts) > 1 else '' - - continue - - if line.startswith('@admin_password'): - parts = line.split(' ', 1) - admin_password = parts[1] if len(parts) > 1 else '' - - continue - - return admin_username, admin_password - - -# def compute_signature(method, uri, body, key): -# parts = list(urlparse.urlsplit(uri)) -# query = [q for q in urlparse.parse_qsl(parts[3]) if (q[0] != 'signature')] -# query.sort(key=lambda q: q[0]) -# query = urllib.urlencode(query) -# parts[0] = parts[1] = '' -# parts[3] = query -# uri = urlparse.urlunsplit(parts) -# -# return hashlib.sha1('%s:%s:%s:%s' % (method, uri, body or '', key)).hexdigest().lower() - - -if __name__ == '__main__': - if len(sys.argv) < 3: - print_usage() - sys.exit(-1) - - event = sys.argv[1] - thread_id = sys.argv[2] - - logging.debug('hello!') - logging.debug('event = %s' % event) - logging.debug('thread_id = %s' % thread_id) - - admin_username, admin_password = get_admin_credentials() - - uri = '/_relay_event/?event=%(event)s&thread_id=%(thread_id)s&_username=%(username)s' % { - 'username': admin_username, - 'thread_id': thread_id, - 'event': event} - - signature = utils.compute_signature('POST', uri, '', admin_password) - - url = 'http://127.0.0.1:%(port)s' + uri + '&_signature=' + signature - url = url % {'port': settings.PORT} - - try: - response = urllib.urlopen(url, data='') - response = json.load(response) - if response.get('error'): - raise Exception(response['error']) - - logging.debug('event successfully relayed') - - except Exception as e: - logging.error('failed to relay event: %s' % e) - - logging.debug('bye!') diff --git a/motioneye.py b/motioneye.py deleted file mode 100755 index 5f54e4b..0000000 --- a/motioneye.py +++ /dev/null @@ -1,419 +0,0 @@ -#!/usr/bin/env python - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import datetime -import imp -import inspect -import logging -import multiprocessing -import os.path -import re -import signal -import sys - -from tornado.httpclient import AsyncHTTPClient - -# test if a --settings directive has been supplied -for i in xrange(1, len(sys.argv) - 1): - if sys.argv[i] == '--settings': - settings_module = sys.argv[i + 1] - imp.load_source('settings', settings_module) - -sys.path.append(os.path.join(os.path.dirname(sys.argv[0]), 'src')) - -import settings -import update - -VERSION = '0.25.2' - - -def _configure_settings(): - def set_default_setting(name, value): - if not hasattr(settings, name): - setattr(settings, name, value) - - set_default_setting('PROJECT_PATH', os.path.dirname(sys.argv[0])) - set_default_setting('TEMPLATE_PATH', os.path.join(settings.PROJECT_PATH, 'templates')) - set_default_setting('STATIC_PATH', os.path.join(settings.PROJECT_PATH, 'static')) - set_default_setting('STATIC_URL', '/static/') - set_default_setting('CONF_PATH', os.path.join(settings.PROJECT_PATH, 'conf')) - set_default_setting('RUN_PATH', os.path.join(settings.PROJECT_PATH, 'run')) - set_default_setting('LOG_PATH', os.path.join(settings.PROJECT_PATH, 'log')) - set_default_setting('MEDIA_PATH', os.path.join(settings.PROJECT_PATH, 'media')) - set_default_setting('MOTION_BINARY', None) - set_default_setting('LOG_LEVEL', logging.INFO) - set_default_setting('LISTEN', '0.0.0.0') - set_default_setting('PORT', 8765) - set_default_setting('MOUNT_CHECK_INTERVAL', 300) - set_default_setting('MOTION_CHECK_INTERVAL', 10) - set_default_setting('CLEANUP_INTERVAL', 43200) - set_default_setting('THUMBNAILER_INTERVAL', 60) - set_default_setting('REMOTE_REQUEST_TIMEOUT', 10) - set_default_setting('MJPG_CLIENT_TIMEOUT', 10) - set_default_setting('MJPG_CLIENT_IDLE_TIMEOUT', 10) - set_default_setting('SMB_SHARES', False) - set_default_setting('SMB_MOUNT_ROOT', '/media') - set_default_setting('WPA_SUPPLICANT_CONF', None) - set_default_setting('LOCAL_TIME_FILE', None) - set_default_setting('ENABLE_REBOOT', False) - set_default_setting('SMTP_TIMEOUT', 60) - set_default_setting('ZIP_TIMEOUT', 500) - set_default_setting('ADD_REMOVE_CAMERAS', True) - - length = len(sys.argv) - 1 - for i in xrange(length): - arg = sys.argv[i + 1] - - if not arg.startswith('--'): - continue - - next_arg = None - if i < length - 1: - next_arg = sys.argv[i + 2] - - name = arg[2:].upper().replace('-', '_') - - if name == 'HELP': - _print_help() - sys.exit(0) - - if hasattr(settings, name): - curr_value = getattr(settings, name) - - if next_arg.lower() == 'debug': - next_arg = logging.DEBUG - - elif next_arg.lower() == 'info': - next_arg = logging.INFO - - elif next_arg.lower() == 'warn': - next_arg = logging.WARN - - elif next_arg.lower() == 'error': - next_arg = logging.ERROR - - elif next_arg.lower() == 'fatal': - next_arg = logging.FATAL - - elif next_arg.lower() == 'true': - next_arg = True - - elif next_arg.lower() == 'false': - next_arg = False - - elif isinstance(curr_value, int): - next_arg = int(next_arg) - - elif isinstance(curr_value, float): - next_arg = float(next_arg) - - setattr(settings, name, next_arg) - - else: - return arg[2:] - - try: - os.makedirs(settings.CONF_PATH) - - except: - pass - - try: - os.makedirs(settings.RUN_PATH) - - except: - pass - - try: - os.makedirs(settings.LOG_PATH) - - except: - pass - - try: - os.makedirs(settings.MEDIA_PATH) - - except: - pass - - -def _test_requirements(): - if os.geteuid() != 0: - if settings.SMB_SHARES: - print('SMB_SHARES require root privileges') - return False - - if settings.ENABLE_REBOOT: - print('reboot requires root privileges') - return False - - try: - import tornado # @UnusedImport - has_tornado = True - - except ImportError: - has_tornado = False - - if update.compare_versions(tornado.version, '3.1') < 0: - has_tornado = False - - try: - import jinja2 # @UnusedImport - has_jinja2 = True - - except ImportError: - has_jinja2 = False - - try: - import PIL.Image # @UnusedImport - has_pil = True - - except ImportError: - has_pil = False - - try: - import pycurl # @UnusedImport - has_pycurl = True - - except ImportError: - has_pycurl = False - - import mediafiles - has_ffmpeg = mediafiles.find_ffmpeg() is not None - - import motionctl - has_motion = motionctl.find_motion() is not None - - import v4l2ctl - has_v4lutils = v4l2ctl.find_v4l2_ctl() is not None - - import smbctl - has_mount_cifs = smbctl.find_mount_cifs() is not None - - ok = True - if not has_tornado: - print('please install tornado (python-tornado), version 3.1 or greater') - ok = False - - if not has_jinja2: - print('please install jinja2 (python-jinja2)') - ok = False - - if not has_pil: - print('please install PIL (python-imaging)') - ok = False - - if not has_pycurl: - print('please install pycurl (python-pycurl)') - ok = False - - if not has_ffmpeg: - print('please install ffmpeg') - ok = False - - if not has_motion: - print('please install motion') - ok = False - - if not has_v4lutils: - print('please install v4l-utils') - ok = False - - if settings.SMB_SHARES and not has_mount_cifs: - print('please install cifs-utils') - ok = False - - return ok - - -def _configure_signals(): - def bye_handler(signal, frame): - import tornado.ioloop - - logging.info('interrupt signal received, shutting down...') - - # shut down the IO loop if it has been started - ioloop = tornado.ioloop.IOLoop.instance() - ioloop.stop() - - def child_handler(signal, frame): - # this is required for the multiprocessing mechanism to work - multiprocessing.active_children() - - signal.signal(signal.SIGINT, bye_handler) - signal.signal(signal.SIGTERM, bye_handler) - signal.signal(signal.SIGCHLD, child_handler) - - -def _configure_logging(module=None): - if module: - format = '%(asctime)s: [{module}] %(levelname)s: %(message)s'.format(module=module) - - else: - format = '%(asctime)s: %(levelname)s: %(message)s' - - logging.basicConfig(filename=None, level=settings.LOG_LEVEL, - format=format, datefmt='%Y-%m-%d %H:%M:%S') - - logging.getLogger('tornado').setLevel(logging.WARN) - - -def _configure_tornado(): - AsyncHTTPClient.configure('tornado.curl_httpclient.CurlAsyncHTTPClient', max_clients=16) - - -def _print_help(): - print('Usage: ' + sys.argv[0] + ' [option1 value1] ...') - print('available options: ') - - options = list(inspect.getmembers(settings)) - - print(' --settings ') - - for (name, value) in sorted(options): - if name.upper() != name: - continue - - if not re.match('^[A-Z0-9_]+$', name): - continue - - name = '--' + name.lower().replace('_', '-') - if value is not None: - value = type(value).__name__ - - line = ' ' + name - if value: - line += ' <' + value + '>' - print(line) - - print('') - - -def _run_server(): - import cleanup - import motionctl - import thumbnailer - import tornado.ioloop - import server - import smbctl - - server.application.listen(settings.PORT, settings.LISTEN) - logging.info('server started') - - tornado.ioloop.IOLoop.instance().start() - - logging.info('server stopped') - - if thumbnailer.running(): - thumbnailer.stop() - logging.info('thumbnailer stopped') - - if cleanup.running(): - cleanup.stop() - logging.info('cleanup stopped') - - if motionctl.running(): - motionctl.stop() - logging.info('motion stopped') - - if settings.SMB_SHARES: - smbctl.umount_all() - logging.info('SMB shares unmounted') - - -def _start_motion(): - import tornado.ioloop - import config - import motionctl - - ioloop = tornado.ioloop.IOLoop.instance() - - # add a motion running checker - def checker(): - if ioloop._stopped: - return - - if not motionctl.running() and motionctl.started() and config.get_enabled_local_motion_cameras(): - try: - logging.error('motion not running, starting it') - motionctl.start() - - except Exception as e: - logging.error('failed to start motion: %(msg)s' % { - 'msg': unicode(e)}, exc_info=True) - - ioloop.add_timeout(datetime.timedelta(seconds=settings.MOTION_CHECK_INTERVAL), checker) - - motionctl.start() - - ioloop.add_timeout(datetime.timedelta(seconds=settings.MOTION_CHECK_INTERVAL), checker) - - -def _start_cleanup(): - import cleanup - - cleanup.start() - logging.info('cleanup started') - - -def _start_wsswitch(): - import wsswitch - - wsswitch.start() - logging.info('wsswitch started') - - -def _start_thumbnailer(): - import thumbnailer - - thumbnailer.start() - logging.info('thumbnailer started') - - -if __name__ == '__main__': - cmd = _configure_settings() - - _configure_signals() - _configure_logging() - - if not _test_requirements(): - sys.exit(-1) - - _configure_tornado() - - logging.info('hello! this is motionEye %s' % VERSION) - - if settings.SMB_SHARES: - import smbctl - - stop, start = smbctl.update_mounts() - if start: - _start_motion() - - else: - _start_motion() - - _start_cleanup() - _start_wsswitch() - - if settings.THUMBNAILER_INTERVAL: - _start_thumbnailer() - - _run_server() - - logging.info('bye!') \ No newline at end of file diff --git a/motioneye/__init__.py b/motioneye/__init__.py new file mode 100644 index 0000000..ad23e4c --- /dev/null +++ b/motioneye/__init__.py @@ -0,0 +1,2 @@ + +VERSION = '0.26' diff --git a/motioneye/cleanup.py b/motioneye/cleanup.py new file mode 100644 index 0000000..a032ef1 --- /dev/null +++ b/motioneye/cleanup.py @@ -0,0 +1,96 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import logging +import multiprocessing +import os +import signal +import tornado + +import mediafiles +import settings +import thumbnailer + + +_process = None + + +def start(): + # schedule the first call a bit later to improve performance at startup + ioloop = tornado.ioloop.IOLoop.instance() + ioloop.add_timeout(datetime.timedelta(seconds=60), _run_process) + + +def stop(): + global _process + + if not running(): + _process = None + return + + if _process.is_alive(): + _process.join(timeout=10) + + if _process.is_alive(): + logging.error('cleanup process did not finish in time, killing it...') + os.kill(_process.pid, signal.SIGKILL) + + _process = None + + +def running(): + return _process is not None and _process.is_alive() + + +def _run_process(): + global _process + + ioloop = tornado.ioloop.IOLoop.instance() + + if thumbnailer.running(): + # postpone if thumbnailer is currently running + ioloop.add_timeout(datetime.timedelta(seconds=60), _run_process) + + return + + else: + # schedule the next call + ioloop.add_timeout(datetime.timedelta(seconds=settings.CLEANUP_INTERVAL), _run_process) + + if not running(): # check that the previous process has finished + logging.debug('running cleanup process...') + + _process = multiprocessing.Process(target=_do_cleanup) + _process.start() + + +def _do_cleanup(): + # this will be executed in a separate subprocess + + # ignore the terminate and interrupt signals in this subprocess + signal.signal(signal.SIGINT, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + try: + mediafiles.cleanup_media('picture') + mediafiles.cleanup_media('movie') + logging.debug('cleanup done') + + except Exception as e: + logging.error('failed to cleanup media files: %(msg)s' % { + 'msg': unicode(e)}, exc_info=True) diff --git a/motioneye/config.py b/motioneye/config.py new file mode 100644 index 0000000..235d763 --- /dev/null +++ b/motioneye/config.py @@ -0,0 +1,1789 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import collections +import datetime +import errno +import logging +import os.path +import re +import shlex +import subprocess +import urlparse + +from tornado.ioloop import IOLoop + +import diskctl +import powerctl +import settings +import update +import utils +import v4l2ctl + +from utils import OrderedDict + + +_CAMERA_CONFIG_FILE_NAME = 'thread-%(id)s.conf' +_MAIN_CONFIG_FILE_NAME = 'motion.conf' + +_main_config_cache = None +_camera_config_cache = {} +_camera_ids_cache = None +_additional_section_funcs = [] +_additional_config_funcs = [] +_additional_structure_cache = {} + +# starting with r490 motion config directives have changed a bit +_LAST_OLD_CONFIG_VERSIONS = (490, '3.2.12') + +_KNOWN_MOTION_OPTIONS = set([ + 'auto_brightness', 'brightness', 'contrast', 'emulate_motion', 'event_gap', 'ffmpeg_bps', 'ffmpeg_output_movies', 'ffmpeg_variable_bitrate', 'ffmpeg_video_codec', + 'framerate', 'height', 'hue', 'lightswitch', 'locate_motion_mode', 'locate_motion_style', 'minimum_motion_frames', 'movie_filename', 'max_movie_time', 'max_mpeg_time', + 'noise_level', 'noise_tune', 'on_event_end', 'on_event_start', 'output_pictures', 'picture_filename', 'post_capture', 'pre_capture', 'quality', 'rotate', 'saturation', + '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', 'rtsp_uses_tcp' +]) + + +def additional_section(func): + _additional_section_funcs.append(func) + + +def additional_config(func): + _additional_config_funcs.append(func) + + +import wifictl # @UnusedImport +import tzctl # @UnusedImport + + +def get_main(as_lines=False): + global _main_config_cache + + if not as_lines and _main_config_cache is not None: + return _main_config_cache + + config_file_path = os.path.join(settings.CONF_PATH, _MAIN_CONFIG_FILE_NAME) + + logging.debug('reading main config from file %(path)s...' % {'path': config_file_path}) + + lines = None + try: + file = open(config_file_path, 'r') + + except IOError as e: + if e.errno == errno.ENOENT: # file does not exist + logging.info('main config file %(path)s does not exist, using default values' % {'path': config_file_path}) + + lines = [] + + else: + logging.error('could not open main config file %(path)s: %(msg)s' % { + 'path': config_file_path, 'msg': unicode(e)}) + + raise + + if lines is None: + try: + lines = [l[:-1] for l in file.readlines()] + + except Exception as e: + logging.error('could not read main config file %(path)s: %(msg)s' % { + 'path': config_file_path, 'msg': unicode(e)}) + + raise + + finally: + file.close() + + if as_lines: + return lines + + main_config = _conf_to_dict(lines, + list_names=['thread'], + no_convert=['@admin_username', '@admin_password', '@normal_username', '@normal_password']) + + _get_additional_config(main_config) + _set_default_motion(main_config, old_motion=is_old_motion()) + + _main_config_cache = main_config + + return main_config + + +def set_main(main_config): + global _main_config_cache + + main_config = dict(main_config) + _set_default_motion(main_config, old_motion=is_old_motion()) + for n, v in _main_config_cache.iteritems(): + main_config.setdefault(n, v) + _main_config_cache = main_config + + main_config = dict(main_config) + _set_additional_config(main_config) + + config_file_path = os.path.join(settings.CONF_PATH, _MAIN_CONFIG_FILE_NAME) + + # read the actual configuration from file + lines = get_main(as_lines=True) + + # write the configuration to file + logging.debug('writing main config to %(path)s...' % {'path': config_file_path}) + + try: + file = open(config_file_path, 'w') + + except Exception as e: + logging.error('could not open main config file %(path)s for writing: %(msg)s' % { + 'path': config_file_path, 'msg': unicode(e)}) + + raise + + lines = _dict_to_conf(lines, main_config, list_names=['thread']) + + try: + file.writelines([utils.make_str(l) + '\n' for l in lines]) + + except Exception as e: + logging.error('could not write main config file %(path)s: %(msg)s' % { + 'path': config_file_path, 'msg': unicode(e)}) + + raise + + finally: + file.close() + + +def get_camera_ids(filter_valid=True): + global _camera_ids_cache + + if _camera_ids_cache is not None: + return _camera_ids_cache + + config_path = settings.CONF_PATH + + logging.debug('listing config dir %(path)s...' % {'path': config_path}) + + try: + ls = os.listdir(config_path) + + except Exception as e: + logging.error('failed to list config dir %(path)s: %(msg)s', { + 'path': config_path, 'msg': unicode(e)}) + + raise + + camera_ids = [] + + pattern = '^' + _CAMERA_CONFIG_FILE_NAME.replace('%(id)s', '(\d+)') + '$' + for name in ls: + match = re.match(pattern, name) + if match: + camera_id = int(match.groups()[0]) + logging.debug('found camera with id %(id)s' % { + 'id': camera_id}) + + camera_ids.append(camera_id) + + camera_ids.sort() + + if not filter_valid: + return camera_ids + + filtered_camera_ids = [] + for camera_id in camera_ids: + if get_camera(camera_id): + filtered_camera_ids.append(camera_id) + + _camera_ids_cache = filtered_camera_ids + + return filtered_camera_ids + + +def get_enabled_local_motion_cameras(): + if not get_main().get('@enabled'): + return [] + + camera_ids = get_camera_ids() + cameras = [get_camera(camera_id) for camera_id in camera_ids] + return [c for c in cameras if c.get('@enabled') and utils.local_motion_camera(c)] + + +def get_network_shares(): + if not get_main().get('@enabled'): + return [] + + camera_ids = get_camera_ids() + cameras = [get_camera(camera_id) for camera_id in camera_ids] + + mounts = [] + for camera in cameras: + if camera.get('@storage_device') != 'network-share': + continue + + mounts.append({ + 'server': camera['@network_server'], + 'share': camera['@network_share_name'], + 'username': camera['@network_username'], + 'password': camera['@network_password'], + }) + + return mounts + + +def get_camera(camera_id, as_lines=False): + global _camera_config_cache + + if not as_lines and camera_id in _camera_config_cache: + return _camera_config_cache[camera_id] + + camera_config_path = os.path.join(settings.CONF_PATH, _CAMERA_CONFIG_FILE_NAME) % {'id': camera_id} + + logging.debug('reading camera config from %(path)s...' % {'path': camera_config_path}) + + try: + file = open(camera_config_path, 'r') + + except Exception as e: + logging.error('could not open camera config file: %(msg)s' % {'msg': unicode(e)}) + + raise + + try: + lines = [l.strip() for l in file.readlines()] + + except Exception as e: + logging.error('could not read camera config file %(path)s: %(msg)s' % { + 'path': camera_config_path, 'msg': unicode(e)}) + + raise + + finally: + file.close() + + if as_lines: + return lines + + camera_config = _conf_to_dict(lines, + no_convert=['@name', '@network_share_name', '@network_server', + '@network_username', '@network_password', '@storage_device']) + + if utils.local_motion_camera(camera_config): + # determine the enabled status + main_config = get_main() + threads = main_config.get('thread', []) + camera_config['@enabled'] = _CAMERA_CONFIG_FILE_NAME % {'id': camera_id} in threads + camera_config['@id'] = camera_id + + old_motion = is_old_motion() + + # adapt directives from old configuration, if needed + if old_motion: + logging.debug('using old motion config directives') + + if 'output_normal' in camera_config: + camera_config['output_pictures'] = camera_config.pop('output_normal') + if 'output_all' in camera_config: + camera_config['emulate_motion'] = camera_config.pop('output_all') + if 'ffmpeg_cap_new' in camera_config: + camera_config['ffmpeg_output_movies'] = camera_config.pop('ffmpeg_cap_new') + if 'locate' in camera_config: + camera_config['locate_motion_mode'] = camera_config.pop('locate') + if 'jpeg_filename' in camera_config: + camera_config['picture_filename'] = camera_config.pop('jpeg_filename') + if 'max_mpeg_time' in camera_config: + camera_config['max_movie_time'] = camera_config.pop('max_mpeg_time') + if 'webcam_port' in camera_config: + camera_config['stream_port'] = camera_config.pop('webcam_port') + if 'webcam_quality' in camera_config: + camera_config['stream_quality'] = camera_config.pop('webcam_quality') + if 'webcam_motion' in camera_config: + camera_config['stream_motion'] = camera_config.pop('webcam_motion') + if 'webcam_maxrate' in camera_config: + camera_config['stream_maxrate'] = camera_config.pop('webcam_maxrate') + if 'webcam_localhost' in camera_config: + camera_config['stream_localhost'] = camera_config.pop('webcam_localhost') + if 'gap' in camera_config: + camera_config['event_gap'] = camera_config.pop('gap') + if 'netcam_http' in camera_config: + camera_config['netcam_keepalive'] = camera_config.pop('netcam_http') in ['1.1', 'keepalive'] + + _get_additional_config(camera_config, camera_id=camera_id) + _set_default_motion_camera(camera_id, camera_config) + + elif utils.remote_camera(camera_config): + pass + + elif utils.simple_mjpeg_camera(camera_config): + _get_additional_config(camera_config, camera_id=camera_id) + + else: # incomplete configuration + logging.warn('camera config file at %s is incomplete, ignoring' % camera_config_path) + + return None + + _camera_config_cache[camera_id] = dict(camera_config) + + return camera_config + + +def set_camera(camera_id, camera_config): + global _camera_config_cache + + camera_config['@id'] = camera_id + _camera_config_cache[camera_id] = camera_config + + camera_config = dict(camera_config) + + if utils.local_motion_camera(camera_config): + old_motion = is_old_motion() + + # adapt directives to old configuration, if needed + if old_motion: + logging.debug('using old motion config directives') + + if 'output_pictures' in camera_config: + camera_config['output_normal'] = camera_config.pop('output_pictures') + if 'emulate_motion' in camera_config: + camera_config['output_all'] = camera_config.pop('emulate_motion') + if 'ffmpeg_output_movies' in camera_config: + camera_config['ffmpeg_cap_new'] = camera_config.pop('ffmpeg_output_movies') + if 'locate_motion_mode' in camera_config: + camera_config['locate'] = camera_config.pop('locate_motion_mode') + if 'picture_filename' in camera_config: + camera_config['jpeg_filename'] = camera_config.pop('picture_filename') + if 'max_movie_time' in camera_config: + camera_config['max_mpeg_time'] = camera_config.pop('max_movie_time') + if 'stream_port' in camera_config: + camera_config['webcam_port'] = camera_config.pop('stream_port') + if 'stream_quality' in camera_config: + camera_config['webcam_quality'] = camera_config.pop('stream_quality') + if 'stream_motion' in camera_config: + camera_config['webcam_motion'] = camera_config.pop('stream_motion') + if 'stream_maxrate' in camera_config: + camera_config['webcam_maxrate'] = camera_config.pop('stream_maxrate') + if 'stream_localhost' in camera_config: + camera_config['webcam_localhost'] = camera_config.pop('stream_localhost') + if 'stream_auth_method' in camera_config: + camera_config.pop('stream_auth_method') + if 'stream_authentication' in camera_config: + camera_config.pop('stream_authentication') + if 'event_gap' in camera_config: + camera_config['gap'] = camera_config.pop('event_gap') + if 'netcam_keepalive' in camera_config: + camera_config['netcam_http'] = '1.1' if camera_config.pop('netcam_keepalive') else '1.0' + + _set_default_motion_camera(camera_id, camera_config, old_motion) + + # set the enabled status in main config + main_config = get_main() + threads = main_config.setdefault('thread', []) + config_file_name = _CAMERA_CONFIG_FILE_NAME % {'id': camera_id} + if camera_config['@enabled'] and config_file_name not in threads: + threads.append(config_file_name) + + elif not camera_config['@enabled']: + threads = [t for t in threads if t != config_file_name] + + main_config['thread'] = threads + + set_main(main_config) + _set_additional_config(camera_config, camera_id=camera_id) + + elif utils.remote_camera(camera_config): + pass + + elif utils.simple_mjpeg_camera(camera_config): + _set_additional_config(camera_config, camera_id=camera_id) + + # read the actual configuration from file + config_file_path = os.path.join(settings.CONF_PATH, _CAMERA_CONFIG_FILE_NAME) % {'id': camera_id} + if os.path.isfile(config_file_path): + lines = get_camera(camera_id, as_lines=True) + + else: + lines = [] + + # write the configuration to file + camera_config_path = os.path.join(settings.CONF_PATH, _CAMERA_CONFIG_FILE_NAME) % {'id': camera_id} + logging.debug('writing camera config to %(path)s...' % {'path': camera_config_path}) + + try: + file = open(camera_config_path, 'w') + + except Exception as e: + logging.error('could not open camera config file %(path)s for writing: %(msg)s' % { + 'path': camera_config_path, 'msg': unicode(e)}) + + raise + + lines = _dict_to_conf(lines, camera_config) + + try: + file.writelines([utils.make_str(l) + '\n' for l in lines]) + + except Exception as e: + logging.error('could not write camera config file %(path)s: %(msg)s' % { + 'path': camera_config_path, 'msg': unicode(e)}) + + raise + + finally: + file.close() + + +def add_camera(device_details): + global _camera_ids_cache + global _camera_config_cache + + proto = device_details['proto'] + if proto in ['netcam', 'mjpeg']: + host = device_details['host'] + if device_details['port']: + host += ':' + str(device_details['port']) + + if device_details['username'] and proto == 'mjpeg': + if device_details['password']: + host = device_details['username'] + ':' + device_details['password'] + '@' + host + + else: + host = device_details['username'] + '@' + host + + device_details['url'] = urlparse.urlunparse((device_details['scheme'], host, device_details['uri'], '', '', '')) + + # determine the last camera id + camera_ids = get_camera_ids() + + camera_id = 1 + while camera_id in camera_ids: + camera_id += 1 + + logging.info('adding new camera with id %(id)s...' % {'id': camera_id}) + + # prepare a default camera config + camera_config = {'@enabled': True} + if proto == 'v4l2': + # find a suitable resolution + for (w, h) in v4l2ctl.list_resolutions(device_details['uri']): + if w > 300: + camera_config['width'] = w + camera_config['height'] = h + break + + camera_config['videodevice'] = device_details['uri'] + _set_default_motion_camera(camera_id, camera_config) + + elif proto == 'motioneye': + camera_config['@proto'] = 'motioneye' + camera_config['@scheme'] = device_details['scheme'] + camera_config['@host'] = device_details['host'] + camera_config['@port'] = device_details['port'] + camera_config['@uri'] = device_details['uri'] + camera_config['@username'] = device_details['username'] + camera_config['@password'] = device_details['password'] + camera_config['@remote_camera_id'] = device_details['remote_camera_id'] + + 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'] + + camera_config['netcam_keepalive'] = device_details.get('keep_alive') + camera_config['netcam_tolerant_check'] = True + + 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 + camera_config['@proto'] = 'mjpeg' + camera_config['@url'] = device_details['url'] + _set_default_simple_mjpeg_camera(camera_id, camera_config) + + # write the configuration to file + set_camera(camera_id, camera_config) + + _camera_ids_cache = None + _camera_config_cache = {} + + camera_config = get_camera(camera_id) + + return camera_config + + +def rem_camera(camera_id): + global _camera_ids_cache + global _camera_config_cache + + camera_config_name = _CAMERA_CONFIG_FILE_NAME % {'id': camera_id} + camera_config_path = os.path.join(settings.CONF_PATH, _CAMERA_CONFIG_FILE_NAME) % {'id': camera_id} + + # remove the camera from the main config + main_config = get_main() + threads = main_config.setdefault('thread', []) + threads = [t for t in threads if t != camera_config_name] + + main_config['thread'] = threads + + set_main(main_config) + + logging.info('removing camera config file %(path)s...' % {'path': camera_config_path}) + + _camera_ids_cache = None + _camera_config_cache = {} + + try: + os.remove(camera_config_path) + + except Exception as e: + logging.error('could not remove camera config file %(path)s: %(msg)s' % { + 'path': camera_config_path, 'msg': unicode(e)}) + + raise + + +def main_ui_to_dict(ui): + data = { + '@enabled': ui['enabled'], + + '@show_advanced': ui['show_advanced'], + '@admin_username': ui['admin_username'], + '@admin_password': ui['admin_password'], + '@normal_username': ui['normal_username'], + '@normal_password': ui['normal_password'] + } + + # additional configs + for name, value in ui.iteritems(): + if not name.startswith('_'): + continue + + data['@' + name] = value + + return data + + +def main_dict_to_ui(data): + ui = { + 'enabled': data['@enabled'], + + 'show_advanced': data['@show_advanced'], + 'admin_username': data['@admin_username'], + 'admin_password': data['@admin_password'], + 'normal_username': data['@normal_username'], + 'normal_password': data['@normal_password'] + } + + # additional configs + for name, value in data.iteritems(): + if not name.startswith('@_'): + continue + + ui[name[1:]] = value + + return ui + + +def motion_camera_ui_to_dict(ui, old_config=None): + import smbctl + + old_config = dict(old_config or {}) + main_config = get_main() # needed for surveillance password + + data = { + # device + '@name': ui['name'], + '@enabled': ui['enabled'], + 'lightswitch': int(ui['light_switch_detect']) * 50, + 'auto_brightness': ui['auto_brightness'], + 'framerate': int(ui['framerate']), + 'rotate': int(ui['rotation']), + + # file storage + '@storage_device': ui['storage_device'], + '@network_server': ui['network_server'], + '@network_share_name': ui['network_share_name'], + '@network_username': ui['network_username'], + '@network_password': ui['network_password'], + + # text overlay + 'text_left': '', + 'text_right': '', + 'text_double': False, + + # streaming + 'stream_localhost': not ui['video_streaming'], + 'stream_port': int(ui['streaming_port']), + 'stream_maxrate': int(ui['streaming_framerate']), + 'stream_quality': max(1, int(ui['streaming_quality'])), + '@webcam_resolution': max(1, int(ui['streaming_resolution'])), + '@webcam_server_resize': ui['streaming_server_resize'], + 'stream_motion': ui['streaming_motion'], + 'stream_auth_method': {'disabled': 0, 'basic': 1, 'digest': 2}.get(ui['streaming_auth_mode'], 0), + 'stream_authentication': main_config['@normal_username'] + ':' + main_config['@normal_password'], + + # still images + 'output_pictures': False, + 'emulate_motion': False, + 'snapshot_interval': 0, + 'picture_filename': '', + 'snapshot_filename': '', + '@preserve_pictures': int(ui['preserve_pictures']), + + # motion detection + '@motion_detection': ui['motion_detection'], + 'text_changes': ui['show_frame_changes'], + 'locate_motion_mode': ui['show_frame_changes'], + 'noise_tune': ui['auto_noise_detect'], + 'noise_level': max(1, int(round(int(ui['noise_level']) * 2.55))), + 'event_gap': int(ui['event_gap']), + 'pre_capture': int(ui['pre_capture']), + 'post_capture': int(ui['post_capture']), + 'minimum_motion_frames': int(ui['minimum_motion_frames']), + + # movies + 'ffmpeg_output_movies': ui['motion_movies'], + 'movie_filename': ui['movie_file_name'], + 'ffmpeg_bps': 44000, # a quality of about 85% for 320x240x2fps + 'max_movie_time': ui['max_movie_length'], + '@preserve_movies': int(ui['preserve_movies']), + + # working schedule + '@working_schedule': '', + + # events + 'on_event_start': '', + 'on_event_end': '' + } + + if utils.v4l2_camera(old_config): + proto = 'v4l2' + + else: + proto = 'netcam' + + if proto == 'v4l2': + # leave videodevice unchanged + + # resolution + if not ui['resolution']: + ui['resolution'] = '320x240' + + 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) + + if 'brightness' in ui: + if int(ui['brightness']) == 50: + data['brightness'] = 0 + + else: + data['brightness'] = max(1, int(round(int(ui['brightness']) * 2.55))) + + if 'contrast' in ui: + if int(ui['contrast']) == 50: + data['contrast'] = 0 + + else: + data['contrast'] = max(1, int(round(int(ui['contrast']) * 2.55))) + + if 'saturation' in ui: + if int(ui['saturation']) == 50: + data['saturation'] = 0 + + else: + data['saturation'] = max(1, int(round(int(ui['saturation']) * 2.55))) + + if 'hue' in ui: + if int(ui['hue']) == 50: + data['hue'] = 0 + + else: + data['hue'] = max(1, int(round(int(ui['hue']) * 2.55))) + + else: # assuming netcam + 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 + + if (ui['storage_device'] == 'network-share') and settings.SMB_SHARES: + mount_point = smbctl.make_mount_point(ui['network_server'], ui['network_share_name'], ui['network_username']) + if ui['root_directory'].startswith('/'): + ui['root_directory'] = ui['root_directory'][1:] + data['target_dir'] = os.path.normpath(os.path.join(mount_point, ui['root_directory'])) + + elif ui['storage_device'].startswith('local-disk'): + target_dev = ui['storage_device'][10:].replace('-', '/') + mounted_partitions = diskctl.list_mounted_partitions() + partition = mounted_partitions[target_dev] + mount_point = partition['mount_point'] + + if ui['root_directory'].startswith('/'): + ui['root_directory'] = ui['root_directory'][1:] + data['target_dir'] = os.path.normpath(os.path.join(mount_point, ui['root_directory'])) + + else: + data['target_dir'] = ui['root_directory'] + + if ui['text_overlay']: + left_text = ui['left_text'] + if left_text == 'camera-name': + data['text_left'] = ui['name'] + + elif left_text == 'timestamp': + data['text_left'] = '%Y-%m-%d\\n%T' + + elif left_text == 'disabled': + data['text_left'] = '' + + else: + data['text_left'] = ui['custom_left_text'] + + right_text = ui['right_text'] + if right_text == 'camera-name': + data['text_right'] = ui['name'] + + elif right_text == 'timestamp': + data['text_right'] = '%Y-%m-%d\\n%T' + + elif right_text == 'disabled': + data['text_right'] = '' + + else: + data['text_right'] = ui['custom_right_text'] + + if proto == 'netcam' or data['width'] > 320: + data['text_double'] = True + + if ui['still_images']: + capture_mode = ui['capture_mode'] + if capture_mode == 'motion-triggered': + data['output_pictures'] = True + data['picture_filename'] = ui['image_file_name'] + + elif capture_mode == 'interval-snapshots': + data['snapshot_interval'] = int(ui['snapshot_interval']) + data['snapshot_filename'] = ui['image_file_name'] + + elif capture_mode == 'all-frames': + data['output_pictures'] = True + data['emulate_motion'] = True + data['picture_filename'] = ui['image_file_name'] + + data['quality'] = max(1, int(ui['image_quality'])) + + if proto == 'v4l2': + max_val = data['width'] * data['height'] * data['framerate'] / 3 + + else: # always assume a netcam image size of 640x480, since we have no means to know it at this point + max_val = 640 * 480 * data['framerate'] / 3 + + max_val = min(max_val, 9999999) + + data['ffmpeg_bps'] = int(ui['movie_quality']) * max_val / 100 + + # working schedule + if ui['working_schedule']: + data['@working_schedule'] = ( + ui['monday_from'] + '-' + ui['monday_to'] + '|' + + ui['tuesday_from'] + '-' + ui['tuesday_to'] + '|' + + ui['wednesday_from'] + '-' + ui['wednesday_to'] + '|' + + ui['thursday_from'] + '-' + ui['thursday_to'] + '|' + + ui['friday_from'] + '-' + ui['friday_to'] + '|' + + ui['saturday_from'] + '-' + ui['saturday_to'] + '|' + + ui['sunday_from'] + '-' + ui['sunday_to']) + + data['@working_schedule_type'] = ui['working_schedule_type'] + + # event start + event_relay_path = os.path.join(settings.PROJECT_PATH, 'eventrelay.py') + event_relay_path = os.path.abspath(event_relay_path) + + on_event_start = ['%(script)s start %%t' % {'script': event_relay_path}] + if ui['email_notifications_enabled']: + send_mail_path = os.path.join(settings.PROJECT_PATH, 'sendmail.py') + send_mail_path = os.path.abspath(send_mail_path) + emails = re.sub('\\s', '', ui['email_notifications_addresses']) + + on_event_start.append("%(script)s '%(server)s' '%(port)s' '%(account)s' '%(password)s' '%(tls)s' '%(to)s' 'motion_start' '%%t' '%%Y-%%m-%%dT%%H:%%M:%%S' '%(timespan)s'" % { + 'script': send_mail_path, + 'server': ui['email_notifications_smtp_server'], + 'port': ui['email_notifications_smtp_port'], + 'account': ui['email_notifications_smtp_account'], + 'password': ui['email_notifications_smtp_password'], + 'tls': ui['email_notifications_smtp_tls'], + 'to': emails, + 'timespan': ui['email_notifications_picture_time_span']}) + + if ui['web_hook_notifications_enabled']: + web_hook_path = os.path.join(settings.PROJECT_PATH, 'webhook.py') + web_hook_path = os.path.abspath(web_hook_path) + url = re.sub('\\s', '+', ui['web_hook_notifications_url']) + + on_event_start.append("%(script)s '%(method)s' '%(url)s'" % { + 'script': web_hook_path, + 'method': ui['web_hook_notifications_http_method'], + 'url': url}) + + if ui['command_notifications_enabled']: + commands = ui['command_notifications_exec'].split(';') + on_event_start += [c.strip() for c in commands] + + data['on_event_start'] = '; '.join(on_event_start) + + # event end + on_event_end = ['%(script)s stop %%t' % {'script': event_relay_path}] + + data['on_event_end'] = '; '.join(on_event_end) + + # additional configs + for name, value in ui.iteritems(): + if not name.startswith('_'): + continue + + data['@' + name] = value + + # extra motion options + for name in old_config.keys(): + if name not in _KNOWN_MOTION_OPTIONS and not name.startswith('@'): + old_config.pop(name) + + extra_options = ui.get('extra_options', []) + for name, value in extra_options: + data[name] = value or '' + + old_config.update(data) + + return old_config + + +def motion_camera_dict_to_ui(data): + import smbctl + + ui = { + # device + 'name': data['@name'], + 'enabled': data['@enabled'], + 'id': data['@id'], + 'light_switch_detect': data['lightswitch'] > 0, + 'auto_brightness': data['auto_brightness'], + 'framerate': int(data['framerate']), + 'rotation': int(data['rotate']), + + # file storage + 'smb_shares': settings.SMB_SHARES, + 'storage_device': data['@storage_device'], + 'network_server': data['@network_server'], + 'network_share_name': data['@network_share_name'], + 'network_username': data['@network_username'], + 'network_password': data['@network_password'], + 'disk_used': 0, + 'disk_total': 0, + 'available_disks': diskctl.list_mounted_disks(), + + # text overlay + 'text_overlay': False, + 'left_text': 'camera-name', + 'right_text': 'timestamp', + 'custom_left_text': '', + 'custom_right_text': '', + + # streaming + 'video_streaming': not data['stream_localhost'], + 'streaming_framerate': int(data['stream_maxrate']), + 'streaming_quality': int(data['stream_quality']), + 'streaming_resolution': int(data['@webcam_resolution']), + 'streaming_server_resize': data['@webcam_server_resize'], + 'streaming_port': int(data['stream_port']), + 'streaming_auth_mode': {0: 'disabled', 1: 'basic', 2: 'digest'}.get(data.get('stream_auth_method'), 'disabled'), + 'streaming_motion': int(data['stream_motion']), + + # still images + 'still_images': False, + 'capture_mode': 'motion-triggered', + 'image_file_name': '%Y-%m-%d/%H-%M-%S', + 'image_quality': 85, + 'snapshot_interval': 0, + 'preserve_pictures': data['@preserve_pictures'], + + # motion detection + 'motion_detection': data['@motion_detection'], + 'show_frame_changes': data['text_changes'] or data['locate_motion_mode'], + 'auto_noise_detect': data['noise_tune'], + 'noise_level': int(int(data['noise_level']) / 2.55), + 'event_gap': int(data['event_gap']), + 'pre_capture': int(data['pre_capture']), + 'post_capture': int(data['post_capture']), + 'minimum_motion_frames': int(data['minimum_motion_frames']), + + # motion movies + 'motion_movies': data['ffmpeg_output_movies'], + 'movie_file_name': data['movie_filename'], + 'max_movie_length': data['max_movie_time'], + 'preserve_movies': data['@preserve_movies'], + + # motion notifications + 'email_notifications_enabled': False, + 'web_hook_notifications_enabled': False, + 'command_notifications_enabled': False, + + # working schedule + 'working_schedule': False, + 'working_schedule_type': 'during', + 'monday_from': '', 'monday_to': '', + 'tuesday_from': '', 'tuesday_to': '', + 'wednesday_from': '', 'wednesday_to': '', + 'thursday_from': '', 'thursday_to': '', + 'friday_from': '', 'friday_to': '', + 'saturday_from': '', 'saturday_to': '', + 'sunday_from': '', 'sunday_to': '' + } + + if utils.net_camera(data): + ui['device_url'] = data['netcam_url'] + ui['proto'] = 'netcam' + + # 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' + + # resolutions + resolutions = v4l2ctl.list_resolutions(data['videodevice']) + ui['available_resolutions'] = [(str(w) + 'x' + str(h)) for (w, h) in resolutions] + ui['resolution'] = str(data['width']) + 'x' + str(data['height']) + + # the brightness & co. keys in the ui dictionary + # indicate the presence of these controls + # we must call v4l2ctl functions to determine the available controls + brightness = v4l2ctl.get_brightness(data['videodevice']) + if brightness is not None: # has brightness control + if data.get('brightness', 0) != 0: + ui['brightness'] = brightness + + else: + ui['brightness'] = 50 + + contrast = v4l2ctl.get_contrast(data['videodevice']) + if contrast is not None: # has contrast control + if data.get('contrast', 0) != 0: + ui['contrast'] = contrast + + else: + ui['contrast'] = 50 + + saturation = v4l2ctl.get_saturation(data['videodevice']) + if saturation is not None: # has saturation control + if data.get('saturation', 0) != 0: + ui['saturation'] = saturation + + else: + ui['saturation'] = 50 + + hue = v4l2ctl.get_hue(data['videodevice']) + if hue is not None: # has hue control + if data.get('hue', 0) != 0: + ui['hue'] = hue + + else: + ui['hue'] = 50 + + threshold = data['threshold'] * 100.0 / (data['width'] * data['height']) + + ui['frame_change_threshold'] = threshold + + if (data['@storage_device'] == 'network-share') and settings.SMB_SHARES: + mount_point = smbctl.make_mount_point(data['@network_server'], data['@network_share_name'], data['@network_username']) + ui['root_directory'] = data['target_dir'][len(mount_point):] or '/' + + elif data['@storage_device'].startswith('local-disk'): + target_dev = data['@storage_device'][10:].replace('-', '/') + mounted_partitions = diskctl.list_mounted_partitions() + for partition in mounted_partitions.values(): + if partition['target'] == target_dev and data['target_dir'].startswith(partition['mount_point']): + ui['root_directory'] = data['target_dir'][len(partition['mount_point']):] or '/' + break + + else: # not found for some reason + logging.error('could not find mounted partition for device "%s" and target dir "%s"' % (target_dev, data['target_dir'])) + ui['root_directory'] = data['target_dir'] + + else: + ui['root_directory'] = data['target_dir'] + + # disk usage + usage = utils.get_disk_usage(data['target_dir']) + if usage: + ui['disk_used'], ui['disk_total'] = usage + + text_left = data['text_left'] + text_right = data['text_right'] + if text_left or text_right: + ui['text_overlay'] = True + + if text_left == data['@name']: + ui['left_text'] = 'camera-name' + + elif text_left == '%Y-%m-%d\\n%T': + ui['left_text'] = 'timestamp' + + elif text_left == '': + ui['left_text'] = 'disabled' + + else: + ui['left_text'] = 'custom-text' + ui['custom_left_text'] = text_left + + if text_right == data['@name']: + ui['right_text'] = 'camera-name' + + elif text_right == '%Y-%m-%d\\n%T': + ui['right_text'] = 'timestamp' + + elif text_right == '': + ui['right_text'] = 'disabled' + + else: + ui['right_text'] = 'custom-text' + ui['custom_right_text'] = text_right + + emulate_motion = data['emulate_motion'] + output_pictures = data['output_pictures'] + picture_filename = data['picture_filename'] + snapshot_interval = data['snapshot_interval'] + snapshot_filename = data['snapshot_filename'] + + if (((emulate_motion or output_pictures) and picture_filename) or + (snapshot_interval and snapshot_filename)): + + ui['still_images'] = True + + if emulate_motion: + ui['capture_mode'] = 'all-frames' + ui['image_file_name'] = picture_filename + + elif snapshot_interval: + ui['capture_mode'] = 'interval-snapshots' + ui['image_file_name'] = snapshot_filename + ui['snapshot_interval'] = snapshot_interval + + elif output_pictures: + ui['capture_mode'] = 'motion-triggered' + ui['image_file_name'] = picture_filename + + ui['image_quality'] = data['quality'] + + ffmpeg_bps = data['ffmpeg_bps'] + if ffmpeg_bps is not None: + if utils.v4l2_camera(data): + max_val = data['width'] * data['height'] * data['framerate'] / 3 + + else: # net camera + max_val = 640 * 480 * data['framerate'] / 3 + + max_val = min(max_val, 9999999) + + ui['movie_quality'] = min(100, int(round(ffmpeg_bps * 100.0 / max_val))) + + # working schedule + working_schedule = data['@working_schedule'] + if working_schedule: + days = working_schedule.split('|') + ui['working_schedule'] = True + ui['monday_from'], ui['monday_to'] = days[0].split('-') + ui['tuesday_from'], ui['tuesday_to'] = days[1].split('-') + ui['wednesday_from'], ui['wednesday_to'] = days[2].split('-') + ui['thursday_from'], ui['thursday_to'] = days[3].split('-') + ui['friday_from'], ui['friday_to'] = days[4].split('-') + ui['saturday_from'], ui['saturday_to'] = days[5].split('-') + ui['sunday_from'], ui['sunday_to'] = days[6].split('-') + ui['working_schedule_type'] = data['@working_schedule_type'] + + # event start + on_event_start = data.get('on_event_start') or [] + if on_event_start: + on_event_start = [e.strip() for e in on_event_start.split(';')] + + ui['email_notifications_picture_time_span'] = 0 + command_notifications = [] + for e in on_event_start: + if e.count('sendmail.py') and e.count('motion_start'): + e = shlex.split(e) + if len(e) < 10: + continue + + ui['email_notifications_enabled'] = True + ui['email_notifications_smtp_server'] = e[1] + ui['email_notifications_smtp_port'] = e[2] + ui['email_notifications_smtp_account'] = e[3] + ui['email_notifications_smtp_password'] = e[4] + ui['email_notifications_smtp_tls'] = e[5].lower() == 'true' + ui['email_notifications_addresses'] = e[6] + try: + ui['email_notifications_picture_time_span'] = int(e[10]) + + except: + ui['email_notifications_picture_time_span'] = 0 + + elif e.count('webhook.py'): + e = shlex.split(e) + if len(e) != 3: + continue + + ui['web_hook_notifications_enabled'] = True + ui['web_hook_notifications_http_method'] = e[1] + ui['web_hook_notifications_url'] = e[2] + + elif e.count('eventrelay.py'): + continue # ignore internal relay script + + else: # custom command + command_notifications.append(e) + + if command_notifications: + ui['command_notifications_enabled'] = True + ui['command_notifications_exec'] = '; '.join(command_notifications) + + # additional configs + for name, value in data.iteritems(): + if not name.startswith('@_'): + continue + + ui[name[1:]] = value + + # extra motion options + extra_options = [] + for name, value in data.iteritems(): + if name not in _KNOWN_MOTION_OPTIONS and not name.startswith('@'): + extra_options.append((name, value)) + + ui['extra_options'] = extra_options + + return ui + + +def simple_mjpeg_camera_ui_to_dict(ui, old_config=None): + old_config = dict(old_config or {}) + + data = { + # device + '@name': ui['name'], + '@enabled': ui['enabled'], + } + + # additional configs + for name, value in ui.iteritems(): + if not name.startswith('_'): + continue + + data['@' + name] = value + + old_config.update(data) + + return old_config + + +def simple_mjpeg_camera_dict_to_ui(data): + ui = { + 'name': data['@name'], + 'enabled': data['@enabled'], + 'id': data['@id'], + 'proto': 'mjpeg', + 'url': data['@url'] + } + + # additional configs + for name, value in data.iteritems(): + if not name.startswith('@_'): + continue + + ui[name[1:]] = value + + return ui + + +def backup(): + logging.debug('generating config backup file') + + if len(os.listdir(settings.CONF_PATH)) > 100: + logging.debug('config path "%s" appears to be a system-wide config directory, performing a selective backup' % settings.CONF_PATH) + cmd = 'cd "%s" && tar zc motion.conf thread-*.conf' % settings.CONF_PATH + try: + content = subprocess.check_output(cmd, shell=True) + logging.debug('backup file created (%s bytes)' % len(content)) + + return content + + except Exception as e: + logging.error('backup failed: %s' % e, exc_info=True) + + return None + + else: + logging.debug('config path "%s" appears to be a motion-specific config directory, performing a full backup' % settings.CONF_PATH) + + cmd = 'cd "%s" && tar zc .' % settings.CONF_PATH + try: + content = subprocess.check_output(cmd, shell=True) + logging.debug('backup file created (%s bytes)' % len(content)) + + return content + + except Exception as e: + logging.error('backup failed: %s' % e, exc_info=True) + + return None + + +def restore(content): + global _main_config_cache + global _camera_config_cache + global _camera_ids_cache + global _additional_structure_cache + + logging.info('restoring config from backup file') + + cmd = 'tar zxC "%s" || true' % settings.CONF_PATH + + try: + p = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + msg = p.communicate(content)[0] + if msg: + logging.error('failed to restore configuration: %s' % msg) + return False + + logging.debug('configuration restored successfully') + + if settings.ENABLE_REBOOT: + def later(): + powerctl.reboot() + + IOLoop.instance().add_timeout(datetime.timedelta(seconds=2), later) + + else: + logging.info('invalidating config cache') + invalidate() + + return {'reboot': settings.ENABLE_REBOOT} + + except Exception as e: + logging.error('failed to restore configuration: %s' % e, exc_info=True) + + return None + + +def is_old_motion(): + import motionctl + + try: + binary, version = motionctl.find_motion() # @UnusedVariable + + if version.startswith('trunkREV'): # e.g. trunkREV599 + version = int(version[8:]) + 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 + + else: # stable release, should be in the format x.y.z + return update.compare_versions(version, _LAST_OLD_CONFIG_VERSIONS[1]) <= 0 + + except: + 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 + global _camera_ids_cache + global _additional_structure_cache + + logging.debug('invalidating config cache') + _main_config_cache = None + _camera_config_cache = {} + _camera_ids_cache = None + _additional_structure_cache = {} + + +def _value_to_python(value): + value_lower = value.lower() + if value_lower == 'off': + return False + + elif value_lower == 'on': + return True + + try: + return int(value) + + except ValueError: + try: + return float(value) + + except ValueError: + return value + + +def _python_to_value(value): + if value is True: + return 'on' + + elif value is False: + return 'off' + + elif isinstance(value, (int, float)): + return str(value) + + else: + return value + + +def _conf_to_dict(lines, list_names=[], no_convert=[]): + data = OrderedDict() + + for line in lines: + line = line.strip() + if len(line) == 0: # empty line + continue + + if line.startswith(';'): # comment line + continue + + match = re.match('^\#\s*(\@\w+)\s*(.*)', line) + if match: + name, value = match.groups()[:2] + + elif line.startswith('#') or line.startswith(';'): # comment line + continue + + else: + parts = line.split(None, 1) + if len(parts) == 1: # empty value + parts.append('') + + (name, value) = parts + value = value.strip() + + if name not in no_convert: + value = _value_to_python(value) + + if name in list_names: + data.setdefault(name, []).append(value) + + else: + data[name] = value + + return data + + +def _dict_to_conf(lines, data, list_names=[]): + conf_lines = [] + remaining = OrderedDict(data) + processed = set() + + # parse existing lines and replace the values + + for line in lines: + line = line.strip() + if len(line) == 0: # empty line + conf_lines.append(line) + continue + + if line.startswith(';'): # simple comment line + conf_lines.append(line) + continue + + match = re.match('^\#\s*(\@\w+)\s*(.*)', line) + if match: # @line + (name, value) = match.groups()[:2] + + elif line.startswith('#'): # simple comment line + conf_lines.append(line) + continue + + else: + parts = line.split(None, 1) + if len(parts) == 2: + (name, value) = parts + + else: + (name, value) = parts[0], '' + + if name in processed: + continue # name already processed + + processed.add(name) + + if name in list_names: + new_value = data.get(name) + if new_value is not None: + for v in new_value: + if v is None: + continue + + line = name + ' ' + _python_to_value(v) + conf_lines.append(line) + + else: + line = name + ' ' + value + conf_lines.append(line) + + else: + new_value = data.get(name) + if new_value is not None: + value = _python_to_value(new_value) + line = name + ' ' + value + conf_lines.append(line) + + remaining.pop(name, None) + + # add the remaining config values not covered by existing lines + + if len(remaining) and len(lines): + conf_lines.append('') # add a blank line + + for (name, value) in remaining.iteritems(): + if name.startswith('@_'): + continue # ignore additional configs + + if name in list_names: + for v in value: + if v is None: + continue + + line = name + ' ' + _python_to_value(v) + conf_lines.append(line) + + else: + line = name + ' ' + _python_to_value(value) + conf_lines.append(line) + + # build the final config lines + conf_lines.sort(key=lambda l: not l.startswith('@')) + + lines = [] + for i, line in enumerate(conf_lines): + # squeeze successive blank lines + if i > 0 and len(line.strip()) == 0 and len(conf_lines[i - 1].strip()) == 0: + continue + + if line.startswith('@'): + line = '# ' + line + + elif i > 0 and conf_lines[i - 1].startswith('@'): + lines.append('') # add a blank line between @lines and the rest + + lines.append(line) + + return lines + + +def _set_default_motion(data, old_motion): + data.setdefault('@enabled', True) + + data.setdefault('@show_advanced', False) + data.setdefault('@admin_username', 'admin') + data.setdefault('@admin_password', '') + data.setdefault('@normal_username', 'user') + data.setdefault('@normal_password', '') + + if old_motion: + data.setdefault('control_port', 7999) + + else: + data.setdefault('webcontrol_port', 7999) + + +def _set_default_motion_camera(camera_id, data, old_motion=False): + data.setdefault('@name', 'Camera' + str(camera_id)) + data.setdefault('@enabled', False) + data.setdefault('@id', camera_id) + + if not utils.net_camera(data): + data.setdefault('videodevice', '/dev/video0') + data.setdefault('brightness', 0) + data.setdefault('contrast', 0) + data.setdefault('saturation', 0) + data.setdefault('hue', 0) + data.setdefault('width', 352) + data.setdefault('height', 288) + + data.setdefault('lightswitch', 50) + data.setdefault('auto_brightness', False) + data.setdefault('framerate', 2) + data.setdefault('rotate', 0) + + data.setdefault('@storage_device', 'custom-path') + data.setdefault('@network_server', '') + data.setdefault('@network_share_name', '') + data.setdefault('@network_username', '') + data.setdefault('@network_password', '') + data.setdefault('target_dir', settings.MEDIA_PATH) + + if old_motion: + data.setdefault('webcam_localhost', False) + data.setdefault('webcam_port', int('808' + str(camera_id))) + data.setdefault('webcam_maxrate', 5) + data.setdefault('webcam_quality', 85) + data.setdefault('webcam_motion', False) + + else: + data.setdefault('stream_localhost', False) + data.setdefault('stream_port', int('808' + str(camera_id))) + data.setdefault('stream_maxrate', 5) + data.setdefault('stream_quality', 85) + data.setdefault('stream_motion', False) + data.setdefault('stream_auth_method', 0) + + data.setdefault('@webcam_resolution', 100) + data.setdefault('@webcam_server_resize', False) + + data.setdefault('text_left', data['@name']) + data.setdefault('text_right', '%Y-%m-%d\\n%T') + data.setdefault('text_double', False) + + data.setdefault('@motion_detection', True) + data.setdefault('text_changes', False) + if old_motion: + data.setdefault('locate', False) + + else: + data.setdefault('locate_motion_mode', False) + data.setdefault('locate_motion_style', 'redbox') + + data.setdefault('threshold', 2000) + data.setdefault('noise_tune', True) + data.setdefault('noise_level', 32) + data.setdefault('minimum_motion_frames', 1) + + data.setdefault('pre_capture', 2) + data.setdefault('post_capture', 4) + data.setdefault('minimum_motion_frames', 1) + + if old_motion: + data.setdefault('output_normal', False) + data.setdefault('jpeg_filename', '') + data.setdefault('output_all', False) + data.setdefault('gap', 10) + + else: + data.setdefault('output_pictures', False) + data.setdefault('picture_filename', '') + data.setdefault('emulate_motion', False) + data.setdefault('event_gap', 10) + + data.setdefault('snapshot_interval', 0) + data.setdefault('snapshot_filename', '') + data.setdefault('quality', 85) + data.setdefault('@preserve_pictures', 0) + + data.setdefault('ffmpeg_variable_bitrate', 0) + data.setdefault('ffmpeg_bps', 44000) # a quality of about 85% + data.setdefault('movie_filename', '%Y-%m-%d/%H-%M-%S') + if old_motion: + data.setdefault('max_mpeg_time', 0) + data.setdefault('ffmpeg_cap_new', False) + + else: + data.setdefault('max_movie_time', 0) + data.setdefault('ffmpeg_output_movies', False) + data.setdefault('ffmpeg_video_codec', 'msmpeg4') + data.setdefault('@preserve_movies', 0) + + data.setdefault('@working_schedule', '') + data.setdefault('@working_schedule_type', 'outside') + + data.setdefault('on_event_start', '') + data.setdefault('on_event_end', '') + + +def _set_default_simple_mjpeg_camera(camera_id, data): + data.setdefault('@name', 'Camera' + str(camera_id)) + data.setdefault('@enabled', False) + data.setdefault('@id', camera_id) + + +def get_additional_structure(camera, separators=False): + if _additional_structure_cache.get((camera, separators)) is None: + logging.debug('loading additional config structure for %s, %s separators' % ( + 'camera' if camera else 'main', + 'with' if separators else 'without')) + + # gather sections + sections = OrderedDict() + for func in _additional_section_funcs: + result = func() + if not result: + continue + + if result.get('reboot') and not settings.ENABLE_REBOOT: + continue + + if bool(result.get('camera')) != bool(camera): + continue + + result['name'] = func.func_name + sections[func.func_name] = result + + logging.debug('additional config section: %s' % result['name']) + + configs = OrderedDict() + for func in _additional_config_funcs: + result = func() + if not result: + continue + + if result.get('reboot') and not settings.ENABLE_REBOOT: + continue + + if bool(result.get('camera')) != bool(camera): + continue + + if result['type'] == 'separator' and not separators: + continue + + result['name'] = func.func_name + configs[func.func_name] = result + + section = sections.setdefault(result.get('section'), {}) + section.setdefault('configs', []).append(result) + + logging.debug('additional config item: %s' % result['name']) + + _additional_structure_cache[(camera, separators)] = sections, configs + + return _additional_structure_cache[(camera, separators)] + + +def _get_additional_config(data, camera_id=None): + args = [camera_id] if camera_id else [] + + (sections, configs) = get_additional_structure(camera=bool(camera_id)) + get_funcs = set([c.get('get') for c in configs.itervalues() if c.get('get')]) + get_func_values = collections.OrderedDict((f, f(*args)) for f in get_funcs) + + for name, section in sections.iteritems(): + if not section.get('get'): + continue + + if section.get('get_set_dict'): + data['@_' + name] = get_func_values.get(section['get'], {}).get(name) + + else: + data['@_' + name] = get_func_values.get(section['get']) + + for name, config in configs.iteritems(): + if not config.get('get'): + continue + + if config.get('get_set_dict'): + data['@_' + name] = get_func_values.get(config['get'], {}).get(name) + + else: + data['@_' + name] = get_func_values.get(config['get']) + + +def _set_additional_config(data, camera_id=None): + args = [camera_id] if camera_id else [] + + (sections, configs) = get_additional_structure(camera=bool(camera_id)) + + set_func_values = collections.OrderedDict() + for name, section in sections.iteritems(): + if not section.get('set'): + continue + + if ('@_' + name) not in data: + continue + + if section.get('get_set_dict'): + set_func_values.setdefault(section['set'], {})[name] = data['@_' + name] + + else: + set_func_values[section['set']] = data['@_' + name] + + for name, config in configs.iteritems(): + if not config.get('set'): + continue + + if ('@_' + name) not in data: + continue + + if config.get('get_set_dict'): + set_func_values.setdefault(config['set'], {})[name] = data['@_' + name] + + else: + set_func_values[config['set']] = data['@_' + name] + + for func, value in set_func_values.iteritems(): + func(*(args + [value])) diff --git a/motioneye/diskctl.py b/motioneye/diskctl.py new file mode 100644 index 0000000..b4cd738 --- /dev/null +++ b/motioneye/diskctl.py @@ -0,0 +1,259 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import os +import re +import subprocess + + +def _list_mounts(): + logging.debug('listing mounts...') + + seen_targets = set() + + mounts = [] + with open('/proc/mounts', 'r') as f: + for line in f: + line = line.strip() + if not line: + continue + parts = line.split() + if len(parts) < 4: + continue + + target = parts[0] + mount_point = parts[1] + fstype = parts[2] + opts = parts[3] + + if not os.access(mount_point, os.W_OK): + continue + + if target in seen_targets: + continue # probably a bind mount + + seen_targets.add(target) + + if fstype == 'fuseblk': + fstype = 'ntfs' # most likely + + logging.debug('found mount "%s" at "%s"' % (target, mount_point)) + + mounts.append({ + 'target': target, + 'mount_point': mount_point, + 'fstype': fstype, + 'opts': opts, + }) + + return mounts + + +def _list_disks(): + if os.path.exists('/dev/disk/by-id/'): + return _list_disks_dev_by_id() + + else: # fall back to fdisk -l + return _list_disks_fdisk() + + +def _list_disks_dev_by_id(): + logging.debug('listing disks using /dev/disk/by-id/') + + disks_by_dev = {} + partitions_by_dev = {} + + for entry in os.listdir('/dev/disk/by-id/'): + parts = entry.split('-', 1) + if len(parts) < 2: + continue + + target = os.path.realpath(os.path.join('/dev/disk/by-id/', entry)) + + bus, entry = parts + m = re.search('-part(\d+)$', entry) + if m: + part_no = int(m.group(1)) + entry = re.sub('-part\d+$', '', entry) + + else: + part_no = None + + parts = entry.split('_') + if len(parts) < 2: + vendor = parts[0] + model = '' + + else: + vendor, model = parts[:2] + + if part_no is not None: + logging.debug('found partition "%s" at "%s" on bus "%s": "%s %s"' % (part_no, target, bus, vendor, model)) + + partitions_by_dev[target] = { + 'target': target, + 'bus': bus, + 'vendor': vendor, + 'model': model, + 'part_no': part_no, + 'unmatched': True + } + + else: + logging.debug('found disk at "%s" on bus "%s": "%s %s"' % (target, bus, vendor, model)) + + disks_by_dev[target] = { + 'target': target, + 'bus': bus, + 'vendor': vendor, + 'model': model, + 'partitions': [] + } + + # group partitions by disk + for dev, partition in partitions_by_dev.items(): + for disk_dev, disk in disks_by_dev.items(): + if dev.startswith(disk_dev): + disk['partitions'].append(partition) + partition.pop('unmatched') + + # add separate partitions that did not match any disk + for partition in partitions_by_dev.values(): + if partition.pop('unmatched', False): + disks_by_dev[partition['target']] = partition + partition['partitions'] = [dict(partition)] + + # prepare flat list of disks + disks = disks_by_dev.values() + disks.sort(key=lambda d: d['vendor']) + + for disk in disks: + disk['partitions'].sort(key=lambda p: p['part_no']) + + return disks + + +def _list_disks_fdisk(): + try: + output = subprocess.check_output('fdisk -l 2>/dev/null', shell=True) + + except Exception as e: + logging.error('failed to list disks using "fdisk -l": %s' % e, exc_info=True) + + return [] + + disks = [] + disk = None + + def add_disk(disk): + logging.debug('found disk at "%s" on bus "%s": "%s %s"' % + (disk['target'], disk['bus'], disk['vendor'], disk['model'])) + + for part in disk['partitions']: + logging.debug('found partition "%s" at "%s" on bus "%s": "%s %s"' % + (part['part_no'], part['target'], part['bus'], part['vendor'], part['model'])) + + disks.append(disk) + + for line in output.split('\n'): + line = line.replace('*', '') + line = re.sub('\s+', ' ', line.strip()) + if not line: + continue + + if line.startswith('Disk /dev/'): + if disk and disk['partitions']: + add_disk(disk) + + parts = line.split() + + disk = { + 'target': parts[1].strip(':'), + 'bus': '', + 'vendor': '', + 'model': parts[2] + ' ' + parts[3].strip(','), + 'partitions': [] + } + + elif line.startswith('/dev/') and disk: + parts = line.split() + part_no = re.findall('\d+$', parts[0]) + partition = { + 'part_no': int(part_no[0]) if part_no else None, + 'target': parts[0], + 'bus': '', + 'vendor': '', + 'model': parts[4] + ' ' + ' '.join(parts[6:]), + } + + disk['partitions'].append(partition) + + if disk and disk['partitions']: + add_disk(disk) + + disks.sort(key=lambda d: d['target']) + + for disk in disks: + disk['partitions'].sort(key=lambda p: p['part_no']) + + return disks + + +def list_mounted_disks(): + mounted_disks = [] + + try: + disks = _list_disks() + mounts_by_target = dict((m['target'], m) for m in _list_mounts()) + + for disk in disks: + for partition in disk['partitions']: + mount = mounts_by_target.get(partition['target']) + if mount: + partition.update(mount) + + # filter out unmounted partitions + disk['partitions'] = [p for p in disk['partitions'] if p.get('mount_point')] + + # filter out unmounted disks + mounted_disks = [d for d in disks if d['partitions']] + + except Exception as e: + logging.error('failed to list mounted disks: %s' % e, exc_info=True) + + return mounted_disks + + +def list_mounted_partitions(): + mounted_partitions = {} + + try: + disks = _list_disks() + mounts_by_target = dict((m['target'], m) for m in _list_mounts()) + + for disk in disks: + for partition in disk['partitions']: + mount = mounts_by_target.get(partition['target']) + if mount: + partition.update(mount) + mounted_partitions[partition['target']] = partition + + except Exception as e: + logging.error('failed to list mounted partitions: %s' % e, exc_info=True) + + return mounted_partitions diff --git a/motioneye/eventrelay.py b/motioneye/eventrelay.py new file mode 100755 index 0000000..42c02f3 --- /dev/null +++ b/motioneye/eventrelay.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import errno +import json +import logging +import os.path +import sys +import urllib + +sys.path.append(os.path.join(os.path.dirname(sys.argv[0]),'src')) + +import settings +import utils + +from motioneye import _configure_settings, _configure_logging + + +_configure_settings() +_configure_logging(module='eventrelay') + + +def print_usage(): + print 'Usage: eventrelay.py ' + + +def get_admin_credentials(): + # this shortcut function is a bit faster than using the config module functions + config_file_path = os.path.join(settings.CONF_PATH, 'motion.conf') + + logging.debug('reading main config from file %(path)s...' % {'path': config_file_path}) + + lines = None + try: + file = open(config_file_path, 'r') + + except IOError as e: + if e.errno == errno.ENOENT: # file does not exist + logging.info('main config file %(path)s does not exist, using default values' % {'path': config_file_path}) + + lines = [] + + else: + logging.error('could not open main config file %(path)s: %(msg)s' % { + 'path': config_file_path, 'msg': unicode(e)}) + + raise + + if lines is None: + try: + lines = [l[:-1] for l in file.readlines()] + + except Exception as e: + logging.error('could not read main config file %(path)s: %(msg)s' % { + 'path': config_file_path, 'msg': unicode(e)}) + + raise + + finally: + file.close() + + admin_username = 'admin' + admin_password = '' + for line in lines: + line = line.strip() + if not line.startswith('#'): + continue + + line = line[1:].strip() + if line.startswith('@admin_username'): + parts = line.split(' ', 1) + admin_username = parts[1] if len(parts) > 1 else '' + + continue + + if line.startswith('@admin_password'): + parts = line.split(' ', 1) + admin_password = parts[1] if len(parts) > 1 else '' + + continue + + return admin_username, admin_password + + +# def compute_signature(method, uri, body, key): +# parts = list(urlparse.urlsplit(uri)) +# query = [q for q in urlparse.parse_qsl(parts[3]) if (q[0] != 'signature')] +# query.sort(key=lambda q: q[0]) +# query = urllib.urlencode(query) +# parts[0] = parts[1] = '' +# parts[3] = query +# uri = urlparse.urlunsplit(parts) +# +# return hashlib.sha1('%s:%s:%s:%s' % (method, uri, body or '', key)).hexdigest().lower() + + +if __name__ == '__main__': + if len(sys.argv) < 3: + print_usage() + sys.exit(-1) + + event = sys.argv[1] + thread_id = sys.argv[2] + + logging.debug('hello!') + logging.debug('event = %s' % event) + logging.debug('thread_id = %s' % thread_id) + + admin_username, admin_password = get_admin_credentials() + + uri = '/_relay_event/?event=%(event)s&thread_id=%(thread_id)s&_username=%(username)s' % { + 'username': admin_username, + 'thread_id': thread_id, + 'event': event} + + signature = utils.compute_signature('POST', uri, '', admin_password) + + url = 'http://127.0.0.1:%(port)s' + uri + '&_signature=' + signature + url = url % {'port': settings.PORT} + + try: + response = urllib.urlopen(url, data='') + response = json.load(response) + if response.get('error'): + raise Exception(response['error']) + + logging.debug('event successfully relayed') + + except Exception as e: + logging.error('failed to relay event: %s' % e) + + logging.debug('bye!') diff --git a/motioneye/handlers.py b/motioneye/handlers.py new file mode 100644 index 0000000..120d481 --- /dev/null +++ b/motioneye/handlers.py @@ -0,0 +1,1466 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import json +import logging +import os +import re +import socket +import subprocess + +from tornado.web import RequestHandler, HTTPError, asynchronous +from tornado.ioloop import IOLoop + +import config +import mediafiles +import motionctl +import powerctl +import remote +import settings +import smbctl +import template +import update +import utils +import v4l2ctl + + +class BaseHandler(RequestHandler): + def get_data(self): + keys = self.request.arguments.keys() + data = dict([(key, self.get_argument(key)) for key in keys]) + + for key in self.request.files: + files = self.request.files[key] + if len(files) > 1: + data[key] = files + + elif len(files) > 0: + data[key] = files[0] + + else: + continue + + return data + + def render(self, template_name, content_type='text/html', **context): + self.set_header('Content-Type', content_type) + + content = template.render(template_name, **context) + self.finish(content) + + def finish_json(self, data={}): + self.set_header('Content-Type', 'application/json') + self.finish(json.dumps(data)) + + def get_current_user(self): + main_config = config.get_main() + + username = self.get_argument('_username', None) + signature = self.get_argument('_signature', None) + login = self.get_argument('_login', None) == 'true' + if (username == main_config.get('@admin_username') and + signature == utils.compute_signature(self.request.method, self.request.uri, self.request.body, main_config.get('@admin_password'))): + + return 'admin' + + elif not username and not main_config.get('@normal_password'): # no authentication required for normal user + return 'normal' + + elif (username == main_config.get('@normal_username') and + signature == utils.compute_signature(self.request.method, self.request.uri, self.request.body, main_config.get('@normal_password'))): + + return 'normal' + + elif username and username != '_' and login: + logging.error('authentication failed for user %(user)s' % {'user': username}) + + return None + + def _handle_request_exception(self, exception): + try: + if isinstance(exception, HTTPError): + logging.error(str(exception)) + self.set_status(exception.status_code) + self.finish_json({'error': exception.log_message or getattr(exception, 'reason', None) or str(exception)}) + + else: + logging.error(str(exception), exc_info=True) + self.set_status(500) + self.finish_json({'error': 'internal server error'}) + + except RuntimeError: + pass # nevermind + + @staticmethod + def auth(admin=False, prompt=True): + def decorator(func): + def wrapper(self, *args, **kwargs): + _admin = self.get_argument('_admin', None) == 'true' + + user = self.current_user + if (user is None) or (user != 'admin' and (admin or _admin)): + self.set_header('Content-Type', 'application/json') + + return self.finish_json({'error': 'unauthorized', 'prompt': prompt}) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + def get(self, *args, **kwargs): + raise HTTPError(400, 'method not allowed') + + def post(self, *args, **kwargs): + raise HTTPError(400, 'method not allowed') + + +class NotFoundHandler(BaseHandler): + def get(self): + raise HTTPError(404, 'not found') + + def post(self): + raise HTTPError(404, 'not found') + + +class MainHandler(BaseHandler): + def get(self): + import motioneye + + # additional config + main_sections = config.get_additional_structure(camera=False, separators=True)[0] + camera_sections = config.get_additional_structure(camera=True, separators=True)[0] + + self.render('main.html', + frame=False, + version=motioneye.VERSION, + enable_update=False, + enable_reboot=settings.ENABLE_REBOOT, + add_remove_cameras=settings.ADD_REMOVE_CAMERAS, + main_sections=main_sections, + camera_sections=camera_sections, + hostname=socket.gethostname(), + title=self.get_argument('title', None), + admin_username=config.get_main().get('@admin_username'), + old_motion=config.is_old_motion()) + + +class ConfigHandler(BaseHandler): + @asynchronous + def get(self, camera_id=None, op=None): + if camera_id is not None: + camera_id = int(camera_id) + + if op == 'get': + self.get_config(camera_id) + + elif op == 'list': + self.list() + + elif op == 'backup': + self.backup() + + else: + raise HTTPError(400, 'unknown operation') + + @asynchronous + def post(self, camera_id=None, op=None): + if camera_id is not None: + camera_id = int(camera_id) + + if op == 'set': + self.set_config(camera_id) + + elif op == 'set_preview': + self.set_preview(camera_id) + + elif op == 'add': + self.add_camera() + + elif op == 'rem': + self.rem_camera(camera_id) + + elif op == 'restore': + self.restore() + + else: + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth(admin=True) + def get_config(self, camera_id): + if camera_id: + logging.debug('getting config for camera %(id)s' % {'id': camera_id}) + + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + local_config = config.get_camera(camera_id) + if utils.local_motion_camera(local_config): + ui_config = config.motion_camera_dict_to_ui(local_config) + + self.finish_json(ui_config) + + elif utils.remote_camera(local_config): + def on_response(remote_ui_config=None, error=None): + if error: + return self.finish_json({'error': 'Failed to get remote camera configuration for %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(local_config), 'msg': error}}) + + for key, value in local_config.items(): + remote_ui_config[key.replace('@', '')] = value + + # replace the real device URI with the remote camera URL + remote_ui_config['device_url'] = remote.pretty_camera_url(local_config) + self.finish_json(remote_ui_config) + + remote.get_config(local_config, on_response) + + else: # assuming simple mjpeg camera + ui_config = config.simple_mjpeg_camera_dict_to_ui(local_config) + + self.finish_json(ui_config) + + else: + logging.debug('getting main config') + + ui_config = config.main_dict_to_ui(config.get_main()) + self.finish_json(ui_config) + + @BaseHandler.auth(admin=True) + def set_config(self, camera_id): + try: + ui_config = json.loads(self.request.body) + + except Exception as e: + logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)}) + + raise + + camera_ids = config.get_camera_ids() + + def set_camera_config(camera_id, ui_config, on_finish): + logging.debug('setting config for camera %(id)s...' % {'id': camera_id}) + + if camera_id not in camera_ids: + raise HTTPError(404, 'no such camera') + + local_config = config.get_camera(camera_id) + if utils.local_motion_camera(local_config): + local_config = config.motion_camera_ui_to_dict(ui_config, local_config) + + config.set_camera(camera_id, local_config) + + on_finish(None, True) # (no error, motion needs restart) + + elif utils.remote_camera(local_config): + # update the camera locally + local_config['@enabled'] = ui_config['enabled'] + config.set_camera(camera_id, local_config) + + if ui_config.has_key('name'): + def on_finish_wrapper(error=None): + return on_finish(error, False) + + ui_config['enabled'] = True # never disable the camera remotely + remote.set_config(local_config, ui_config, on_finish_wrapper) + + else: + # when the ui config supplied has only the enabled state + # and no useful fields (such as "name"), + # the camera was probably disabled due to errors + on_finish(None, False) + + else: # assuming simple mjpeg camera + local_config = config.simple_mjpeg_camera_ui_to_dict(ui_config, local_config) + + config.set_camera(camera_id, local_config) + + on_finish(None, False) # (no error, motion doesn't need restart) + + def set_main_config(ui_config): + logging.debug('setting main config...') + + old_main_config = config.get_main() + old_admin_credentials = '%s:%s' % (old_main_config.get('@admin_username', ''), old_main_config.get('@admin_password', '')) + old_normal_credentials = '%s:%s' % (old_main_config.get('@normal_username', ''), old_main_config.get('@normal_password', '')) + + main_config = config.main_ui_to_dict(ui_config) + main_config.setdefault('thread', old_main_config.get('thread', [])) + admin_credentials = '%s:%s' % (main_config.get('@admin_username', ''), main_config.get('@admin_password', '')) + normal_credentials = '%s:%s' % (main_config.get('@normal_username', ''), main_config.get('@normal_password', '')) + + additional_configs = config.get_additional_structure(camera=False)[1] + reboot_config_names = [('@_' + c['name']) for c in additional_configs.values() if c.get('reboot')] + reboot_config_names.append('@admin_password') + reboot = bool([k for k in reboot_config_names if old_main_config.get(k) != main_config.get(k)]) + + config.set_main(main_config) + + reload = False + restart = False + + if admin_credentials != old_admin_credentials: + logging.debug('admin credentials changed, reload needed') + + reload = True + + if normal_credentials != old_normal_credentials: + logging.debug('surveillance credentials changed, all camera configs must be updated') + + # reconfigure all local cameras to update the stream authentication options + for camera_id in config.get_camera_ids(): + local_config = config.get_camera(camera_id) + if not utils.local_motion_camera(local_config): + continue + + ui_config = config.motion_camera_dict_to_ui(local_config) + local_config = config.motion_camera_ui_to_dict(ui_config, local_config) + + config.set_camera(camera_id, local_config) + + restart = True + + if reboot and settings.ENABLE_REBOOT: + logging.debug('system settings changed, reboot needed') + + else: + reboot = False + + return {'reload': reload, 'reboot': reboot, 'restart': restart} + + reload = False # indicates that browser should reload the page + reboot = [False] # indicates that the server will reboot immediately + restart = [False] # indicates that the local motion instance was modified and needs to be restarted + error = [None] + + def finish(): + if reboot[0]: + if settings.ENABLE_REBOOT: + def call_reboot(): + powerctl.reboot() + + ioloop = IOLoop.instance() + ioloop.add_timeout(datetime.timedelta(seconds=2), call_reboot) + return self.finish({'reload': False, 'reboot': True, 'error': None}) + + else: + reboot[0] = False + + if restart[0]: + logging.debug('motion needs to be restarted') + + motionctl.stop() + + if settings.SMB_SHARES: + logging.debug('updating SMB mounts') + stop, start = smbctl.update_mounts() # @UnusedVariable + + if start: + motionctl.start() + + else: + motionctl.start() + + self.finish({'reload': reload, 'reboot': reboot[0], 'error': error[0]}) + + if camera_id is not None: + if camera_id == 0: # multiple camera configs + if len(ui_config) > 1: + logging.debug('setting multiple configs') + + elif len(ui_config) == 0: + logging.warn('no configuration to set') + + self.finish() + + so_far = [0] + def check_finished(e, r): + restart[0] = restart[0] or r + error[0] = error[0] or e + so_far[0] += 1 + + if so_far[0] >= len(ui_config): # finished + finish() + + # make sure main config is handled first + items = ui_config.items() + items.sort(key=lambda (key, cfg): key != 'main') + + for key, cfg in items: + if key == 'main': + result = set_main_config(cfg) + reload = result['reload'] or reload + reboot[0] = result['reboot'] or reboot[0] + restart[0] = result['restart'] or restart[0] + check_finished(None, reload) + + else: + set_camera_config(int(key), cfg, check_finished) + + else: # single camera config + def on_finish(e, r): + error[0] = e + restart[0] = r + finish() + + set_camera_config(camera_id, ui_config, on_finish) + + else: # main config + result = set_main_config(ui_config) + reload = result['reload'] + reboot[0] = result['reboot'] + restart[0] = result['restart'] + + @BaseHandler.auth(admin=True) + def set_preview(self, camera_id): + try: + controls = json.loads(self.request.body) + + except Exception as e: + logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)}) + + raise + + camera_config = config.get_camera(camera_id) + if utils.v4l2_camera(camera_config): + device = camera_config['videodevice'] + + if 'brightness' in controls: + value = int(controls['brightness']) + logging.debug('setting brightness to %(value)s...' % {'value': value}) + + v4l2ctl.set_brightness(device, value) + + if 'contrast' in controls: + value = int(controls['contrast']) + logging.debug('setting contrast to %(value)s...' % {'value': value}) + + v4l2ctl.set_contrast(device, value) + + if 'saturation' in controls: + value = int(controls['saturation']) + logging.debug('setting saturation to %(value)s...' % {'value': value}) + + v4l2ctl.set_saturation(device, value) + + if 'hue' in controls: + value = int(controls['hue']) + logging.debug('setting hue to %(value)s...' % {'value': value}) + + v4l2ctl.set_hue(device, value) + + self.finish_json({}) + + elif utils.remote_camera(camera_config): + def on_response(error=None): + if error: + self.finish_json({'error': error}) + + else: + self.finish_json() + + remote.set_preview(camera_config, controls, on_response) + + else: # not supported + self.finish_json({'error': True}) + + @BaseHandler.auth() + def list(self): + logging.debug('listing cameras') + + proto = self.get_data().get('proto') + if proto == 'motioneye': # remote listing + def on_response(cameras=None, error=None): + if error: + self.finish_json({'error': error}) + + else: + cameras = [c for c in cameras if c.get('enabled')] + self.finish_json({'cameras': cameras}) + + 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}) + + else: + self.finish_json({'cameras': cameras}) + + 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: + self.finish_json({'error': error}) + + else: + self.finish_json({'cameras': cameras}) + + utils.test_mjpeg_url(self.get_data(), auth_modes=['basic', 'digest'], allow_jpeg=False, callback=on_response) + + elif proto == 'v4l2': + configured_devices = set() + for camera_id in config.get_camera_ids(): + data = config.get_camera(camera_id) + if utils.v4l2_camera(data): + configured_devices.add(data['videodevice']) + + cameras = [{'id': d[1], 'name': d[2]} for d in v4l2ctl.list_devices() + if (d[0] not in configured_devices) and (d[1] not in configured_devices)] + + self.finish_json({'cameras': cameras}) + + else: # assuming local motionEye camera listing + cameras = [] + camera_ids = config.get_camera_ids() + if not config.get_main().get('@enabled'): + camera_ids = [] + + length = [len(camera_ids)] + def check_finished(): + if len(cameras) == length[0]: + cameras.sort(key=lambda c: c['id']) + self.finish_json({'cameras': cameras}) + + def on_response_builder(camera_id, local_config): + def on_response(remote_ui_config=None, error=None): + if error: + cameras.append({ + 'id': camera_id, + 'name': '<' + remote.pretty_camera_url(local_config) + '>', + 'enabled': False, + 'streaming_framerate': 1, + 'framerate': 1 + }) + + else: + remote_ui_config['id'] = camera_id + + if not remote_ui_config['enabled'] and local_config['@enabled']: + # if a remote camera is disabled, make sure it's disabled locally as well + local_config['@enabled'] = False + config.set_camera(camera_id, local_config) + + elif remote_ui_config['enabled'] and not local_config['@enabled']: + # if a remote camera is locally disabled, make sure the remote config says the same thing + remote_ui_config['enabled'] = False + + for key, value in local_config.items(): + remote_ui_config[key.replace('@', '')] = value + + cameras.append(remote_ui_config) + + check_finished() + + return on_response + + for camera_id in camera_ids: + local_config = config.get_camera(camera_id) + if local_config is None: + continue + + if utils.local_motion_camera(local_config): + ui_config = config.motion_camera_dict_to_ui(local_config) + cameras.append(ui_config) + check_finished() + + elif utils.remote_camera(local_config): + if local_config.get('@enabled') or self.get_argument('force', None) == 'true': + remote.get_config(local_config, on_response_builder(camera_id, local_config)) + + else: # don't try to reach the remote of the camera is disabled + on_response_builder(camera_id, local_config)(error=True) + + else: # assuming simple mjpeg camera + ui_config = config.simple_mjpeg_camera_dict_to_ui(local_config) + cameras.append(ui_config) + check_finished() + + if length[0] == 0: + self.finish_json({'cameras': []}) + + @BaseHandler.auth(admin=True) + def add_camera(self): + logging.debug('adding new camera') + + try: + device_details = json.loads(self.request.body) + + except Exception as e: + logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)}) + + raise + + camera_config = config.add_camera(device_details) + + if utils.local_motion_camera(camera_config): + motionctl.stop() + + if settings.SMB_SHARES: + stop, start = smbctl.update_mounts() # @UnusedVariable + + if start: + motionctl.start() + + else: + motionctl.start() + + ui_config = config.motion_camera_dict_to_ui(camera_config) + + self.finish_json(ui_config) + + elif utils.remote_camera(camera_config): + def on_response(remote_ui_config=None, error=None): + if error: + return self.finish_json({'error': error}) + + for key, value in camera_config.items(): + remote_ui_config[key.replace('@', '')] = value + + self.finish_json(remote_ui_config) + + remote.get_config(camera_config, on_response) + + else: # assuming simple mjpeg camera + ui_config = config.simple_mjpeg_camera_dict_to_ui(camera_config) + + self.finish_json(ui_config) + + @BaseHandler.auth(admin=True) + def rem_camera(self, camera_id): + logging.debug('removing camera %(id)s' % {'id': camera_id}) + + local = utils.local_motion_camera(config.get_camera(camera_id)) + config.rem_camera(camera_id) + + if local: + motionctl.stop() + motionctl.start() + + self.finish_json() + + @BaseHandler.auth(admin=True) + def backup(self): + content = config.backup() + + filename = 'motioneye-config.tar.gz' + self.set_header('Content-Type', 'application/x-compressed') + self.set_header('Content-Disposition', 'attachment; filename=' + filename + ';') + + self.finish(content) + + @BaseHandler.auth(admin=True) + def restore(self): + try: + content = self.request.files['files'][0]['body'] + + except KeyError: + raise HTTPError(400, 'file attachment required') + + result = config.restore(content) + if result: + self.finish_json({'ok': True, 'reboot': result['reboot']}) + + else: + self.finish_json({'ok': False}) + + +class PictureHandler(BaseHandler): + @asynchronous + def get(self, camera_id, op, filename=None, group=None): + if camera_id is not None: + camera_id = int(camera_id) + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + if op == 'current': + self.current(camera_id) + + elif op == 'list': + self.list(camera_id) + + elif op == 'frame': + self.frame(camera_id) + + elif op == 'download': + self.download(camera_id, filename) + + elif op == 'preview': + self.preview(camera_id, filename) + + elif op == 'zipped': + self.zipped(camera_id, group) + + elif op == 'timelapse': + self.timelapse(camera_id, group) + + else: + raise HTTPError(400, 'unknown operation') + + @asynchronous + def post(self, camera_id, op, filename=None, group=None): + if camera_id is not None: + camera_id = int(camera_id) + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + if op == 'delete': + self.delete(camera_id, filename) + + elif op == 'delete_all': + self.delete_all(camera_id, group) + + else: + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth(prompt=False) + def current(self, camera_id): + self.set_header('Content-Type', 'image/jpeg') + + width = self.get_argument('width', None) + height = self.get_argument('height', None) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + picture = mediafiles.get_current_picture(camera_config, + width=width, + height=height) + + self.set_cookie('motion_detected_' + str(camera_id), str(motionctl.is_motion_detected(camera_id)).lower()) + self.try_finish(picture) + + elif utils.remote_camera(camera_config): + def on_response(motion_detected=False, picture=None, error=None): + self.set_cookie('motion_detected_' + str(camera_id), str(motion_detected).lower()) + self.try_finish(picture) + + remote.get_current_picture(camera_config, width=width, height=height, callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + + @BaseHandler.auth() + def list(self, camera_id): + logging.debug('listing pictures for camera %(id)s' % {'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + def on_media_list(media_list): + if media_list is None: + return self.finish_json({'error': 'Failed to get pictures list.'}) + + self.finish_json({ + 'mediaList': media_list, + 'cameraName': camera_config['@name'] + }) + + mediafiles.list_media(camera_config, media_type='picture', + callback=on_media_list, prefix=self.get_argument('prefix', None)) + + elif utils.remote_camera(camera_config): + def on_response(remote_list=None, error=None): + if error: + return self.finish_json({'error': 'Failed to get picture list for %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.finish_json(remote_list) + + remote.list_media(camera_config, media_type='picture', prefix=self.get_argument('prefix', None), callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + def frame(self, camera_id): + camera_config = config.get_camera(camera_id) + + if utils.local_motion_camera(camera_config) or utils.simple_mjpeg_camera(camera_config) or self.get_argument('title', None) is not None: + self.render('main.html', + frame=True, + camera_id=camera_id, + camera_config=camera_config, + title=self.get_argument('title', camera_config.get('@name', '')), + admin_username=config.get_main().get('@admin_username')) + + elif utils.remote_camera(camera_config): + def on_response(remote_ui_config=None, error=None): + if error: + return self.render('main.html', + frame=True, + camera_id=camera_id, + camera_config=camera_config, + title=self.get_argument('title', '')) + + # issue a fake motion_camera_ui_to_dict() call to transform + # the remote UI values into motion config directives + remote_config = config.motion_camera_ui_to_dict(remote_ui_config) + + self.render('main.html', + frame=True, + camera_id=camera_id, + camera_config=remote_config, + title=self.get_argument('title', remote_config['@name']), + admin_username=config.get_main().get('@admin_username')) + + remote.get_config(camera_config, on_response) + + @BaseHandler.auth() + def download(self, camera_id, filename): + logging.debug('downloading picture %(filename)s of camera %(id)s' % { + 'filename': filename, 'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + content = mediafiles.get_media_content(camera_config, filename, 'picture') + + pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename) + self.set_header('Content-Type', 'image/jpeg') + self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') + + self.finish(content) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to download picture from %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + pretty_filename = os.path.basename(filename) # no camera name available w/o additional request + self.set_header('Content-Type', 'image/jpeg') + self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') + + self.finish(response) + + remote.get_media_content(camera_config, filename=filename, media_type='picture', callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth() + def preview(self, camera_id, filename): + logging.debug('previewing picture %(filename)s of camera %(id)s' % { + 'filename': filename, 'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + content = mediafiles.get_media_preview(camera_config, filename, 'picture', + width=self.get_argument('width', None), + height=self.get_argument('height', None)) + + if content: + self.set_header('Content-Type', 'image/jpeg') + + else: + self.set_header('Content-Type', 'image/svg+xml') + content = open(os.path.join(settings.STATIC_PATH, 'img', 'no-preview.svg')).read() + + self.finish(content) + + elif utils.remote_camera(camera_config): + def on_response(content=None, error=None): + if content: + self.set_header('Content-Type', 'image/jpeg') + + else: + self.set_header('Content-Type', 'image/svg+xml') + content = open(os.path.join(settings.STATIC_PATH, 'img', 'no-preview.svg')).read() + + self.finish(content) + + remote.get_media_preview(camera_config, filename=filename, media_type='picture', + width=self.get_argument('width', None), + height=self.get_argument('height', None), + callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth(admin=True) + def delete(self, camera_id, filename): + logging.debug('deleting picture %(filename)s of camera %(id)s' % { + 'filename': filename, 'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + try: + mediafiles.del_media_content(camera_config, filename, 'picture') + self.finish_json() + + except Exception as e: + self.finish_json({'error': unicode(e)}) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to delete picture from %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.finish_json() + + remote.del_media_content(camera_config, filename=filename, media_type='picture', callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth() + def zipped(self, camera_id, group): + key = self.get_argument('key', None) + camera_config = config.get_camera(camera_id) + + if key: + logging.debug('serving zip file for group %(group)s of camera %(id)s with key %(key)s' % { + 'group': group, 'id': camera_id, 'key': key}) + + if utils.local_motion_camera(camera_config): + data = mediafiles.get_prepared_cache(key) + if not data: + logging.error('prepared cache data for key "%s" does not exist' % key) + + raise HTTPError(404, 'no such key') + + pretty_filename = camera_config['@name'] + '_' + group + pretty_filename = re.sub('[^a-zA-Z0-9]', '_', pretty_filename) + + self.set_header('Content-Type', 'application/zip') + self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + '.zip;') + self.finish(data) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to download zip file from %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.set_header('Content-Type', response['content_type']) + self.set_header('Content-Disposition', response['content_disposition']) + self.finish(response['data']) + + remote.get_zipped_content(camera_config, media_type='picture', key=key, group=group, callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + else: # prepare + logging.debug('preparing zip file for group %(group)s of camera %(id)s' % { + 'group': group, 'id': camera_id}) + + if utils.local_motion_camera(camera_config): + def on_zip(data): + if data is None: + return self.finish_json({'error': 'Failed to create zip file.'}) + + key = mediafiles.set_prepared_cache(data) + logging.debug('prepared zip file for group %(group)s of camera %(id)s with key %(key)s' % { + 'group': group, 'id': camera_id, 'key': key}) + self.finish_json({'key': key}) + + mediafiles.get_zipped_content(camera_config, media_type='picture', group=group, callback=on_zip) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to make zip file at %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.finish_json({'key': response['key']}) + + remote.make_zipped_content(camera_config, media_type='picture', group=group, callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth() + def timelapse(self, camera_id, group): + key = self.get_argument('key', None) + check = self.get_argument('check', False) + camera_config = config.get_camera(camera_id) + + if key: # download + logging.debug('serving timelapse movie for group %(group)s of camera %(id)s with key %(key)s' % { + 'group': group, 'id': camera_id, 'key': key}) + + if utils.local_motion_camera(camera_config): + data = mediafiles.get_prepared_cache(key) + if data is None: + logging.error('prepared cache data for key "%s" does not exist' % key) + + raise HTTPError(404, 'no such key') + + pretty_filename = camera_config['@name'] + '_' + group + pretty_filename = re.sub('[^a-zA-Z0-9]', '_', pretty_filename) + + self.set_header('Content-Type', 'video/x-msvideo') + self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + '.avi;') + self.finish(data) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to download timelapse movie from %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.set_header('Content-Type', response['content_type']) + self.set_header('Content-Disposition', response['content_disposition']) + self.finish(response['data']) + + remote.get_timelapse_movie(camera_config, key, group=group, callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + elif check: + logging.debug('checking timelapse movie status for group %(group)s of camera %(id)s' % { + 'group': group, 'id': camera_id}) + + if utils.local_motion_camera(camera_config): + status = mediafiles.check_timelapse_movie() + if status['progress'] == -1 and status['data']: + key = mediafiles.set_prepared_cache(status['data']) + logging.debug('prepared timelapse movie for group %(group)s of camera %(id)s with key %(key)s' % { + 'group': group, 'id': camera_id, 'key': key}) + self.finish_json({'key': key, 'progress': -1}) + + else: + self.finish_json(status) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to check timelapse movie progress at %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + if response['progress'] == -1 and response.get('key'): + self.finish_json({'key': response['key'], 'progress': -1}) + + else: + self.finish_json(response) + + remote.check_timelapse_movie(camera_config, group=group, callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + else: # start timelapse + interval = int(self.get_argument('interval')) + framerate = int(self.get_argument('framerate')) + + logging.debug('preparing timelapse movie for group %(group)s of camera %(id)s with rate %(framerate)s/%(int)s' % { + 'group': group, 'id': camera_id, 'framerate': framerate, 'int': interval}) + + if utils.local_motion_camera(camera_config): + status = mediafiles.check_timelapse_movie() + if status['progress'] != -1: + self.finish_json({'progress': status['progress']}) # timelapse already active + + else: + mediafiles.make_timelapse_movie(camera_config, framerate, interval, group=group) + self.finish_json({'progress': -1}) + + elif utils.remote_camera(camera_config): + def on_status(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to make timelapse movie at %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + if response['progress'] != -1: + return self.finish_json({'progress': response['progress']}) # timelapse already active + + def on_make(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to make timelapse movie at %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.finish_json({'progress': -1}) + + remote.make_timelapse_movie(camera_config, framerate, interval, group=group, callback=on_make) + + remote.check_timelapse_movie(camera_config, group=group, callback=on_status) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth(admin=True) + def delete_all(self, camera_id, group): + logging.debug('deleting picture group %(group)s of camera %(id)s' % { + 'group': group, 'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + try: + mediafiles.del_media_group(camera_config, group, 'picture') + self.finish_json() + + except Exception as e: + self.finish_json({'error': unicode(e)}) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to delete picture group from %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.finish_json() + + remote.del_media_group(camera_config, group=group, media_type='picture', callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + def try_finish(self, content): + try: + self.finish(content) + + except IOError as e: + logging.warning('could not write response: %(msg)s' % {'msg': unicode(e)}) + + +class MovieHandler(BaseHandler): + @asynchronous + def get(self, camera_id, op, filename=None): + if camera_id is not None: + camera_id = int(camera_id) + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + if op == 'list': + self.list(camera_id) + + elif op == 'download': + self.download(camera_id, filename) + + elif op == 'preview': + self.preview(camera_id, filename) + + else: + raise HTTPError(400, 'unknown operation') + + @asynchronous + def post(self, camera_id, op, filename=None, group=None): + if camera_id is not None: + camera_id = int(camera_id) + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + if op == 'delete': + self.delete(camera_id, filename) + + elif op == 'delete_all': + self.delete_all(camera_id, group) + + else: + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth() + def list(self, camera_id): + logging.debug('listing movies for camera %(id)s' % {'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + def on_media_list(media_list): + if media_list is None: + return self.finish_json({'error': 'Failed to get movies list.'}) + + self.finish_json({ + 'mediaList': media_list, + 'cameraName': camera_config['@name'] + }) + + mediafiles.list_media(camera_config, media_type='movie', + callback=on_media_list, prefix=self.get_argument('prefix', None)) + + elif utils.remote_camera(camera_config): + def on_response(remote_list=None, error=None): + if error: + return self.finish_json({'error': 'Failed to get movie list for %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.finish_json(remote_list) + + remote.list_media(camera_config, media_type='movie', prefix=self.get_argument('prefix', None), callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth() + def download(self, camera_id, filename): + logging.debug('downloading movie %(filename)s of camera %(id)s' % { + 'filename': filename, 'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + content = mediafiles.get_media_content(camera_config, filename, 'movie') + + pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename) + self.set_header('Content-Type', 'video/mpeg') + self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') + + self.finish(content) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to download movie from %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + pretty_filename = os.path.basename(filename) # no camera name available w/o additional request + self.set_header('Content-Type', 'video/mpeg') + self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') + + self.finish(response) + + remote.get_media_content(camera_config, filename=filename, media_type='movie', callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth() + def preview(self, camera_id, filename): + logging.debug('previewing movie %(filename)s of camera %(id)s' % { + 'filename': filename, 'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + content = mediafiles.get_media_preview(camera_config, filename, 'movie', + width=self.get_argument('width', None), + height=self.get_argument('height', None)) + + if content: + self.set_header('Content-Type', 'image/jpeg') + + else: + self.set_header('Content-Type', 'image/svg+xml') + content = open(os.path.join(settings.STATIC_PATH, 'img', 'no-preview.svg')).read() + + self.finish(content) + + elif utils.remote_camera(camera_config): + def on_response(content=None, error=None): + if content: + self.set_header('Content-Type', 'image/jpeg') + + else: + self.set_header('Content-Type', 'image/svg+xml') + content = open(os.path.join(settings.STATIC_PATH, 'img', 'no-preview.svg')).read() + + self.finish(content) + + remote.get_media_preview(camera_config, filename=filename, media_type='movie', + width=self.get_argument('width', None), + height=self.get_argument('height', None), + callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth(admin=True) + def delete(self, camera_id, filename): + logging.debug('deleting movie %(filename)s of camera %(id)s' % { + 'filename': filename, 'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + try: + mediafiles.del_media_content(camera_config, filename, 'movie') + self.finish_json() + + except Exception as e: + self.finish_json({'error': unicode(e)}) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to delete movie from %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.finish_json() + + remote.del_media_content(camera_config, filename=filename, media_type='movie', callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + @BaseHandler.auth(admin=True) + def delete_all(self, camera_id, group): + logging.debug('deleting movie group %(group)s of camera %(id)s' % { + 'group': group, 'id': camera_id}) + + camera_config = config.get_camera(camera_id) + if utils.local_motion_camera(camera_config): + try: + mediafiles.del_media_group(camera_config, group, 'movie') + self.finish_json() + + except Exception as e: + self.finish_json({'error': unicode(e)}) + + elif utils.remote_camera(camera_config): + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to delete movie group from %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + self.finish_json() + + remote.del_media_group(camera_config, group=group, media_type='movie', callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + +class RelayEventHandler(BaseHandler): + @BaseHandler.auth(admin=True) + def post(self): + event = self.get_argument('event') + thread_id = int(self.get_argument('thread_id')) + logging.debug('recevied relayed event %(event)s for thread id %(id)s' % {'event': event, 'id': thread_id}) + + camera_id = motionctl.thread_id_to_camera_id(thread_id) + try: + camera_config = config.get_camera(camera_id) + + except: + logging.warn('ignoring event for remote camera with id %s (probably removed)' % camera_id) + return self.finish_json() + + if not utils.local_motion_camera(camera_config): + logging.warn('ignoring event for non-local camera with id %s' % camera_id) + return self.finish_json() + + if event == 'start': + if not camera_config['@motion_detection']: + logging.debug('ignoring start event for camera with id %s and motion detection disabled' % camera_id) + return self.finish_json() + + motionctl.set_motion_detected(camera_id, True) + + elif event == 'stop': + motionctl.set_motion_detected(camera_id, False) + + else: + logging.warn('unknown event %s' % event) + + self.finish_json() + + +class LogHandler(BaseHandler): + LOGS = { + 'motion': (os.path.join(settings.LOG_PATH, 'motion.log'), 'motion.log'), + } + + @BaseHandler.auth(admin=True) + def get(self, name): + log = self.LOGS.get(name) + if log is None: + raise HTTPError(404, 'no such log') + + (path, filename) = log + + self.set_header('Content-Type', 'text/plain') + self.set_header('Content-Disposition', 'attachment; filename=' + filename + ';') + + if path.startswith('/'): # an actual path + logging.debug('serving log file "%s" from "%s"' % (filename, path)) + + with open(path) as f: + self.finish(f.read()) + + else: # a command to execute + logging.debug('serving log file "%s" from command "%s"' % (filename, path)) + + try: + output = subprocess.check_output(path, shell=True) + + except Exception as e: + output = 'failed to execute command: %s' % e + + self.finish(output) + + +class UpdateHandler(BaseHandler): + @BaseHandler.auth(admin=True) + def get(self): + logging.debug('listing versions') + + versions = update.get_all_versions() + current_version = update.get_version() + update_version = None + if versions and update.compare_versions(versions[-1], current_version) > 0: + update_version = versions[-1] + + self.finish_json({ + 'update_version': update_version, + 'current_version': current_version + }) + + @BaseHandler.auth(admin=True) + def post(self): + version = self.get_argument('version') + + logging.debug('performing update to version %(version)s' % {'version': version}) + + result = update.perform_update(version) + + self.finish_json(result) + + +class PowerHandler(BaseHandler): + @BaseHandler.auth(admin=True) + def post(self, op): + if op == 'shutdown': + self.shut_down() + + elif op == 'reboot': + self.reboot() + + def shut_down(self): + IOLoop.instance().add_timeout(datetime.timedelta(seconds=2), powerctl.shut_down) + + def reboot(self): + IOLoop.instance().add_timeout(datetime.timedelta(seconds=2), powerctl.reboot) + + +class VersionHandler(BaseHandler): + def get(self): + self.render('version.html', + version=update.get_version(), + hostname=socket.gethostname()) + + post = get + + +# this will only trigger the login mechanism on the client side, if required +class LoginHandler(BaseHandler): + @BaseHandler.auth() + def get(self): + self.finish_json() + + def post(self): + self.set_header('Content-Type', 'text/html') + self.finish() diff --git a/motioneye/mediafiles.py b/motioneye/mediafiles.py new file mode 100644 index 0000000..c4f615e --- /dev/null +++ b/motioneye/mediafiles.py @@ -0,0 +1,801 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import errno +import fcntl +import functools +import hashlib +import logging +import multiprocessing +import os.path +import re +import stat +import StringIO +import subprocess +import time +import tornado +import zipfile + +from PIL import Image +from tornado import ioloop + +import config +import settings +import utils + + +_PICTURE_EXTS = ['.jpg'] +_MOVIE_EXTS = ['.avi', '.mp4'] + +# a cache list of paths to movies without preview +_previewless_movie_files = [] + +# a cache of prepared files (whose preparing time is significant) +_prepared_files = {} + +_timelapse_process = None +_timelapse_data = None + + +def _list_media_files(dir, exts, prefix=None): + media_files = [] + + if prefix is not None: + if prefix == 'ungrouped': + prefix = '' + + root = os.path.join(dir, prefix) + for name in os.listdir(root): + if name == 'lastsnap.jpg' or name.startswith('.'): # ignore the lastsnap.jpg and hidden files + continue + + full_path = os.path.join(root, name) + try: + st = os.stat(full_path) + + except Exception as e: + logging.error('stat failed: ' + unicode(e)) + continue + + if not stat.S_ISREG(st.st_mode): # not a regular file + continue + + full_path_lower = full_path.lower() + if not [e for e in exts if full_path_lower.endswith(e)]: + continue + + media_files.append((full_path, st)) + + else: + for root, dirs, files in os.walk(dir): # @UnusedVariable # TODO os.walk can be rewritten to return stat info + for name in files: + if name == 'lastsnap.jpg' or name.startswith('.'): # ignore the lastsnap.jpg and hidden files + continue + + full_path = os.path.join(root, name) + try: + st = os.stat(full_path) + + except Exception as e: + logging.error('stat failed: ' + unicode(e)) + continue + + if not stat.S_ISREG(st.st_mode): # not a regular file + continue + + full_path_lower = full_path.lower() + if not [e for e in exts if full_path_lower.endswith(e)]: + continue + + media_files.append((full_path, st)) + + return media_files + + +def _remove_older_files(dir, moment, exts): + for (full_path, st) in _list_media_files(dir, exts): + file_moment = datetime.datetime.fromtimestamp(st.st_mtime) + if file_moment < moment: + logging.debug('removing file %(path)s...' % {'path': full_path}) + + # remove the file itself + try: + os.remove(full_path) + + except OSError as e: + if e.errno == errno.ENOENT: + pass # the file might have been removed in the meantime + + else: + logging.error('failed to remove %s: %s' % (full_path, e)) + + # remove the parent directories if empty or contain only thumb files + dir_path = os.path.dirname(full_path) + if not os.path.exists(dir_path): + continue + + listing = os.listdir(dir_path) + thumbs = [l for l in listing if l.endswith('.thumb')] + + if len(listing) == len(thumbs): # only thumbs + for p in thumbs: + try: + os.remove(os.path.join(dir_path, p)) + + except: + logging.error('failed to remove %s: %s' % (p, e)) + + if not listing or len(listing) == len(thumbs): + # this will possibly cause following paths that are in the media files for loop + # to be removed in advance; the os.remove call will raise ENOENT which is silently ignored + logging.debug('removing empty directory %(path)s...' % {'path': dir_path}) + try: + os.removedirs(dir_path) + + except: + logging.error('failed to remove %s: %s' % (dir_path, e)) + + +def find_ffmpeg(): + try: + return subprocess.check_output('which ffmpeg', shell=True).strip() + + except subprocess.CalledProcessError: # not found + return None + + +def cleanup_media(media_type): + logging.debug('cleaning up %(media_type)ss...' % {'media_type': media_type}) + + if media_type == 'picture': + exts = _PICTURE_EXTS + + elif media_type == 'movie': + exts = _MOVIE_EXTS + ['.thumb'] + + for camera_id in config.get_camera_ids(): + camera_config = config.get_camera(camera_id) + if not utils.local_motion_camera(camera_config): + continue + + preserve_media = camera_config.get('@preserve_%(media_type)ss' % {'media_type': media_type}, 0) + if preserve_media == 0: + return # preserve forever + + still_images_enabled = bool( + ((camera_config['emulate_motion'] or camera_config['output_pictures']) and camera_config['picture_filename']) or + (camera_config['snapshot_interval'] and camera_config['snapshot_filename'])) + + movies_enabled = camera_config['ffmpeg_output_movies'] + + if media_type == 'picture' and not still_images_enabled: + continue # only cleanup pictures for cameras with still images enabled + + elif media_type == 'movie' and not movies_enabled: + continue # only cleanup movies for cameras with movies enabled + + preserve_moment = datetime.datetime.now() - datetime.timedelta(days=preserve_media) + + target_dir = camera_config.get('target_dir') + _remove_older_files(target_dir, preserve_moment, exts=exts) + + +def make_movie_preview(camera_config, full_path): + framerate = camera_config['framerate'] + pre_capture = camera_config['pre_capture'] + offs = pre_capture / framerate + offs = max(4, offs * 2) + + logging.debug('creating movie preview for %(path)s with an offset of %(offs)s seconds...' % { + 'path': full_path, 'offs': offs}) + + cmd = 'ffmpeg -i "%(path)s" -f mjpeg -vframes 1 -ss %(offs)s -y %(path)s.thumb' + + try: + subprocess.check_output(cmd % {'path': full_path, 'offs': offs}, shell=True, stderr=subprocess.STDOUT) + + except subprocess.CalledProcessError as e: + logging.error('failed to create movie preview for %(path)s: %(msg)s' % { + 'path': full_path, 'msg': unicode(e)}) + + return None + + try: + st = os.stat(full_path + '.thumb') + + except os.error: + logging.error('failed to create movie preview for %(path)s: ffmpeg error' % { + 'path': full_path}) + + return None + + if st.st_size == 0: + logging.debug('movie is too short, grabbing first frame from %(path)s...' % {'path': full_path}) + + # try again, this time grabbing the very first frame + try: + subprocess.check_output(cmd % {'path': full_path, 'offs': 0}, shell=True, stderr=subprocess.STDOUT) + + except subprocess.CalledProcessError as e: + logging.error('failed to create movie preview for %(path)s: %(msg)s' % { + 'path': full_path, 'msg': unicode(e)}) + + return None + + return full_path + '.thumb' + + +def make_next_movie_preview(): + global _previewless_movie_files + + logging.debug('making preview for the next movie...') + + if _previewless_movie_files: + (camera_config, path) = _previewless_movie_files.pop(0) + + make_movie_preview(camera_config, path) + + else: + logging.debug('gathering movies without preview...') + + count = 0 + for camera_id in config.get_camera_ids(): + camera_config = config.get_camera(camera_id) + if not utils.local_motion_camera(camera_config): + continue + + target_dir = camera_config['target_dir'] + + for (full_path, st) in _list_media_files(target_dir, _MOVIE_EXTS): # @UnusedVariable + if os.path.exists(full_path + '.thumb'): + continue + + logging.debug('found a movie without preview: %(path)s' % { + 'path': full_path}) + + _previewless_movie_files.append((camera_config, full_path)) + count += 1 + + logging.debug('found %(count)d movies without preview' % {'count': count}) + + if count: + make_next_movie_preview() + + +def list_media(camera_config, media_type, callback, prefix=None): + target_dir = camera_config.get('target_dir') + + if media_type == 'picture': + exts = _PICTURE_EXTS + + elif media_type == 'movie': + exts = _MOVIE_EXTS + + # create a subprocess to retrieve media files + def do_list_media(pipe): + mf = _list_media_files(target_dir, exts=exts, prefix=prefix) + for (p, st) in mf: + path = p[len(target_dir):] + if not path.startswith('/'): + path = '/' + path + + timestamp = st.st_mtime + size = st.st_size + + pipe.send({ + 'path': path, + 'momentStr': utils.pretty_date_time(datetime.datetime.fromtimestamp(timestamp)), + 'momentStrShort': utils.pretty_date_time(datetime.datetime.fromtimestamp(timestamp), short=True), + 'sizeStr': utils.pretty_size(size), + 'timestamp': timestamp + }) + + pipe.close() + + logging.debug('starting media listing process...') + + (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False) + process = multiprocessing.Process(target=do_list_media, args=(child_pipe, )) + process.start() + + # poll the subprocess to see when it has finished + started = datetime.datetime.now() + media_list = [] + + def read_media_list(): + while parent_pipe.poll(): + media_list.append(parent_pipe.recv()) + + def poll_process(): + ioloop = tornado.ioloop.IOLoop.instance() + if process.is_alive(): # not finished yet + now = datetime.datetime.now() + delta = now - started + if delta.seconds < 120: + ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_process) + read_media_list() + + else: # process did not finish within 2 minutes + logging.error('timeout waiting for the media listing process to finish') + + callback(None) + + else: # finished + read_media_list() + logging.debug('media listing process has returned %(count)s files' % {'count': len(media_list)}) + callback(media_list) + + poll_process() + + +def get_media_content(camera_config, path, media_type): + target_dir = camera_config.get('target_dir') + + full_path = os.path.join(target_dir, path) + + try: + with open(full_path) as f: + return f.read() + + except Exception as e: + logging.error('failed to read file %(path)s: %(msg)s' % { + 'path': full_path, 'msg': unicode(e)}) + + return None + + +def get_zipped_content(camera_config, media_type, group, callback): + target_dir = camera_config.get('target_dir') + + if media_type == 'picture': + exts = _PICTURE_EXTS + + elif media_type == 'movie': + exts = _MOVIE_EXTS + + working = multiprocessing.Value('b') + working.value = True + + # create a subprocess to add files to zip + def do_zip(pipe): + mf = _list_media_files(target_dir, exts=exts, prefix=group) + paths = [] + for (p, st) in mf: # @UnusedVariable + path = p[len(target_dir):] + if path.startswith('/'): + path = path[1:] + + paths.append(path) + + zip_filename = os.path.join(settings.MEDIA_PATH, '.zip-%s' % int(time.time())) + logging.debug('adding %d files to zip file "%s"' % (len(paths), zip_filename)) + + try: + with zipfile.ZipFile(zip_filename, mode='w') as f: + for path in paths: + full_path = os.path.join(target_dir, path) + f.write(full_path, path) + + except Exception as e: + logging.error('failed to create zip file "%s": %s' % (zip_filename, e)) + + working.value = False + pipe.close() + return + + logging.debug('reading zip file "%s" into memory' % zip_filename) + + try: + with open(zip_filename, mode='r') as f: + data = f.read() + + working.value = False + pipe.send(data) + logging.debug('zip data ready') + + except Exception as e: + logging.error('failed to read zip file "%s": %s' % (zip_filename, e)) + working.value = False + + finally: + os.remove(zip_filename) + pipe.close() + + logging.debug('starting zip process...') + + (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False) + process = multiprocessing.Process(target=do_zip, args=(child_pipe, )) + process.start() + + # poll the subprocess to see when it has finished + started = datetime.datetime.now() + + def poll_process(): + ioloop = tornado.ioloop.IOLoop.instance() + if working.value: + now = datetime.datetime.now() + delta = now - started + if delta.seconds < settings.ZIP_TIMEOUT: + ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_process) + + else: # process did not finish within 2 minutes + logging.error('timeout waiting for the zip process to finish') + + callback(None) + + else: # finished + try: + data = parent_pipe.recv() + logging.debug('zip process has returned %d bytes' % len(data)) + + except: + data = None + + callback(data) + + poll_process() + + +def make_timelapse_movie(camera_config, framerate, interval, group): + global _timelapse_process + global _timelapse_data + + target_dir = camera_config.get('target_dir') + + # create a subprocess to retrieve media files + def do_list_media(pipe): + mf = _list_media_files(target_dir, exts=_PICTURE_EXTS, prefix=group) + for (p, st) in mf: + timestamp = st.st_mtime + + pipe.send({ + 'path': p, + 'timestamp': timestamp + }) + + pipe.close() + + logging.debug('starting media listing process...') + + (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False) + _timelapse_process = multiprocessing.Process(target=do_list_media, args=(child_pipe, )) + _timelapse_process.progress = 0 + _timelapse_process.start() + _timelapse_data = None + + started = [datetime.datetime.now()] + media_list = [] + + tmp_filename = os.path.join(settings.MEDIA_PATH, '.%s.avi' % int(time.time())) + + def read_media_list(): + while parent_pipe.poll(): + media_list.append(parent_pipe.recv()) + + def poll_media_list_process(): + ioloop = tornado.ioloop.IOLoop.instance() + if _timelapse_process.is_alive(): # not finished yet + now = datetime.datetime.now() + delta = now - started[0] + if delta.seconds < 300: # the subprocess has 5 minutes to complete its job + ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_media_list_process) + read_media_list() + + else: # process did not finish within 2 minutes + logging.error('timeout waiting for the media listing process to finish') + + _timelapse_process.progress = -1 + + else: # finished + read_media_list() + logging.debug('media listing process has returned %(count)s files' % {'count': len(media_list)}) + + if not media_list: + _timelapse_process.progress = -1 + + return + + pictures = select_pictures(media_list) + make_movie(pictures) + + def select_pictures(media_list): + media_list.sort(key=lambda e: e['timestamp']) + start = media_list[0]['timestamp'] + slices = {} + max_idx = 0 + for m in media_list: + offs = m['timestamp'] - start + pos = float(offs) / interval - 0.5 + idx = int(round(pos)) + max_idx = idx + m['delta'] = abs(pos - idx) + slices.setdefault(idx, []).append(m) + + selected = [] + for i in xrange(max_idx + 1): + slice = slices.get(i) + if not slice: + continue + + selected.append(min(slice, key=lambda m: m['delta'])) + + logging.debug('selected %d/%d media files' % (len(selected), len(media_list))) + + return selected + + def make_movie(pictures): + global _timelapse_process + + cmd = 'rm -f %(tmp_filename)s;' + cmd += 'cat %(jpegs)s | ffmpeg -framerate %(framerate)s -f image2pipe -vcodec mjpeg -i - -vcodec mpeg4 -b:v %(bitrate)s -qscale:v 0.1 -f avi %(tmp_filename)s' + + bitrate = 9999999 + + cmd = cmd % { + 'tmp_filename': tmp_filename, + 'jpegs': ' '.join((('"' + p['path'] + '"') for p in pictures)), + 'framerate': framerate, + 'bitrate': bitrate + } + + logging.debug('executing "%s"' % cmd) + + _timelapse_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) + _timelapse_process.progress = 0.01 # 1% + + # make subprocess stdout pipe non-blocking + fd = _timelapse_process.stdout.fileno() + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + + poll_movie_process(pictures) + + def poll_movie_process(pictures): + global _timelapse_process + global _timelapse_data + + ioloop = tornado.ioloop.IOLoop.instance() + if _timelapse_process.poll() is None: # not finished yet + ioloop.add_timeout(datetime.timedelta(seconds=0.5), functools.partial(poll_movie_process, pictures)) + + try: + output = _timelapse_process.stdout.read() + + except IOError as e: + if e.errno == errno.EAGAIN: + output = '' + + else: + raise + + frame_index = re.findall('frame=\s*(\d+)', output) + try: + frame_index = int(frame_index[-1]) + + except (IndexError, ValueError): + return + + _timelapse_process.progress = max(0.01, float(frame_index) / len(pictures)) + + logging.debug('timelapse progress: %s' % int(100 * _timelapse_process.progress)) + + else: # finished + exit_code = _timelapse_process.poll() + _timelapse_process = None + + if exit_code != 0: + logging.error('ffmpeg process failed') + _timelapse_data = None + + try: + os.remove(tmp_filename) + + except: + pass + + else: + logging.debug('reading timelapse movie file "%s" into memory' % tmp_filename) + + try: + with open(tmp_filename, mode='r') as f: + _timelapse_data = f.read() + + logging.debug('timelapse movie process has returned %d bytes' % len(_timelapse_data)) + + except Exception as e: + logging.error('failed to read timelapse movie file "%s": %s' % (tmp_filename, e)) + + finally: + try: + os.remove(tmp_filename) + + except: + pass + + poll_media_list_process() + + +def check_timelapse_movie(): + if _timelapse_process: + if ((hasattr(_timelapse_process, 'poll') and _timelapse_process.poll() is None) or + (hasattr(_timelapse_process, 'is_alive') and _timelapse_process.is_alive())): + + return {'progress': _timelapse_process.progress, 'data': None} + + else: + return {'progress': _timelapse_process.progress, 'data': _timelapse_data} + + else: + return {'progress': -1, 'data': _timelapse_data} + + +def get_media_preview(camera_config, path, media_type, width, height): + target_dir = camera_config.get('target_dir') + full_path = os.path.join(target_dir, path) + + if media_type == 'movie': + if not os.path.exists(full_path + '.thumb'): + if not make_movie_preview(camera_config, full_path): + return None + + full_path += '.thumb' + + try: + with open(full_path) as f: + content = f.read() + + except Exception as e: + logging.error('failed to read file %(path)s: %(msg)s' % { + 'path': full_path, 'msg': unicode(e)}) + + return None + + if width is height is None: + return content + + sio = StringIO.StringIO(content) + image = Image.open(sio) + width = width and int(width) or image.size[0] + height = height and int(height) or image.size[1] + + image.thumbnail((width, height), Image.LINEAR) + + sio = StringIO.StringIO() + image.save(sio, format='JPEG') + + return sio.getvalue() + + +def del_media_content(camera_config, path, media_type): + target_dir = camera_config.get('target_dir') + + full_path = os.path.join(target_dir, path) + + try: + # remove the file itself + os.remove(full_path) + + # remove the parent directories if empty or contains only thumb files + dir_path = os.path.dirname(full_path) + listing = os.listdir(dir_path) + thumbs = [l for l in listing if l.endswith('.thumb')] + + if len(listing) == len(thumbs): # only thumbs + for p in thumbs: + os.remove(os.path.join(dir_path, p)) + + if not listing or len(listing) == len(thumbs): + logging.debug('removing empty directory %(path)s...' % {'path': dir_path}) + os.removedirs(dir_path) + + except Exception as e: + logging.error('failed to remove file %(path)s: %(msg)s' % { + 'path': full_path, 'msg': unicode(e)}) + + raise + + +def del_media_group(camera_config, group, media_type): + if media_type == 'picture': + exts = _PICTURE_EXTS + + elif media_type == 'movie': + exts = _MOVIE_EXTS + + target_dir = camera_config.get('target_dir') + full_path = os.path.join(target_dir, group) + + mf = _list_media_files(target_dir, exts=exts, prefix=group) + for (path, st) in mf: # @UnusedVariable + try: + os.remove(path) + + except Exception as e: + logging.error('failed to remove file %(path)s: %(msg)s' % { + 'path': full_path, 'msg': unicode(e)}) + + raise + + # remove the group directory if empty or contains only thumb files + listing = os.listdir(full_path) + thumbs = [l for l in listing if l.endswith('.thumb')] + + if len(listing) == len(thumbs): # only thumbs + for p in thumbs: + os.remove(os.path.join(full_path, p)) + + if not listing or len(listing) == len(thumbs): + logging.debug('removing empty directory %(path)s...' % {'path': full_path}) + os.removedirs(full_path) + + +def get_current_picture(camera_config, width, height): + import mjpgclient + + jpg = mjpgclient.get_jpg(camera_config['@id']) + + if jpg is None: + return None + + if width is height is None: + return jpg # no server-side resize needed + + sio = StringIO.StringIO(jpg) + image = Image.open(sio) + + width = width and int(width) or image.size[0] + height = height and int(height) or image.size[1] + + webcam_resolution = camera_config['@webcam_resolution'] + max_width = image.size[0] * webcam_resolution / 100 + max_height = image.size[1] * webcam_resolution / 100 + + width = min(max_width, width) + height = min(max_height, height) + + if width >= image.size[0] and height >= image.size[1]: + return jpg # no enlarging of the picture on the server side + + image.thumbnail((width, height), Image.CUBIC) + + sio = StringIO.StringIO() + image.save(sio, format='JPEG') + + return sio.getvalue() + + +def get_prepared_cache(key): + return _prepared_files.pop(key, None) + + +def set_prepared_cache(data): + key = hashlib.sha1(str(time.time())).hexdigest() + + if key in _prepared_files: + logging.warn('key "%s" already present in prepared cache' % key) + + _prepared_files[key] = data + + def clear(): + if _prepared_files.pop(key, None) is not None: + logging.warn('key "%s" was still present in the prepared cache, removed' % key) + + timeout = 3600 # the user has 1 hour to download the file after creation + ioloop.IOLoop.instance().add_timeout(datetime.timedelta(seconds=timeout), clear) + + return key diff --git a/motioneye/mjpgclient.py b/motioneye/mjpgclient.py new file mode 100644 index 0000000..088a962 --- /dev/null +++ b/motioneye/mjpgclient.py @@ -0,0 +1,301 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import errno +import logging +import re +import socket +import time + +from tornado import iostream, ioloop + +import config +import motionctl +import settings +import utils + + +class MjpgClient(iostream.IOStream): + clients = {} # dictionary of clients indexed by camera id + last_jpgs = {} # dictionary of jpg contents indexed by camera id + last_jpg_moment = {} # dictionary of moments of the last received jpeg indexed by camera id + last_access = {} # dictionary of access moments indexed by camera id + last_erroneous_close_time = 0 # helps detecting erroneous connections and restart motion + + def __init__(self, camera_id, port, username, password): + self._camera_id = camera_id + self._port = port + self._username = (username or '').encode('utf8') + self._password = (password or '').encode('utf8') + self._auth_digest_state = {} + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) + iostream.IOStream.__init__(self, s) + + self.set_close_callback(self.on_close) + + def connect(self): + iostream.IOStream.connect(self, ('localhost', self._port), self._on_connect) + MjpgClient.clients[self._camera_id] = self + + logging.debug('mjpg client for camera %(camera_id)s connecting on port %(port)s...' % { + 'port': self._port, 'camera_id': self._camera_id}) + + def on_close(self): + logging.debug('connection closed for mjpg client for camera %(camera_id)s on port %(port)s' % { + 'port': self._port, 'camera_id': self._camera_id}) + + if MjpgClient.clients.pop(self._camera_id, None): + MjpgClient.last_access.pop(self._camera_id, None) + MjpgClient.last_jpg_moment.pop(self._camera_id, None) + + logging.debug('mjpg client for camera %(camera_id)s on port %(port)s removed' % { + 'port': self._port, 'camera_id': self._camera_id}) + + if getattr(self, 'error', None) and self.error.errno != errno.ECONNREFUSED: + now = time.time() + if now - MjpgClient.last_erroneous_close_time < settings.MJPG_CLIENT_TIMEOUT: + logging.error('connection problem detected for mjpg client for camera %(camera_id)s on port %(port)s' % { + 'port': self._port, 'camera_id': self._camera_id}) + + motionctl.stop(invalidate=True) # this will close all the mjpg clients + motionctl.start(deferred=True) + + MjpgClient.last_erroneous_close_time = now + + def _check_error(self): + if self.socket is None: + logging.warning('mjpg client connection for camera %(camera_id)s on port %(port)s is closed' % { + 'port': self._port, 'camera_id': self._camera_id}) + + self.close() + + return True + + error = getattr(self, 'error', None) + if (error is None) or (getattr(error, 'errno', None) == 0): # error could also be ESUCCESS for some reason + return False + + self._error(error) + + return True + + def _error(self, error): + logging.error('mjpg client for camera %(camera_id)s on port %(port)s error: %(msg)s' % { + 'port': self._port, 'camera_id': self._camera_id, 'msg': unicode(error)}) + + try: + self.close() + + except: + pass + + def _on_connect(self): + logging.debug('mjpg client for camera %(camera_id)s connected on port %(port)s' % { + 'port': self._port, 'camera_id': self._camera_id}) + + if self._username: + auth_header = utils.build_basic_header(self._username, self._password) + self.write('GET / HTTP/1.0\r\n\r\nAuthorization: %s\r\n\r\n' % auth_header) + + else: + self.write('GET / HTTP/1.0\r\n\r\n') + + self._seek_http() + + def _seek_http(self): + if self._check_error(): + return + + self.read_until_regex('HTTP/1.\d \d+ ', self._on_http) + + def _on_http(self, data): + if data.endswith('401 '): + self._seek_www_authenticate() + + else: # no authorization required, skip to content length + self._seek_content_length() + + def _seek_www_authenticate(self): + if self._check_error(): + return + + self.read_until('WWW-Authenticate:', self._on_before_www_authenticate) + + def _on_before_www_authenticate(self, data): + if self._check_error(): + return + + self.read_until('\r\n', self._on_www_authenticate) + + def _on_www_authenticate(self, data): + if self._check_error(): + return + + m = re.match('Basic\s*realm="([a-zA-Z0-9\-\s]+)"', data.strip()) + if m: + logging.debug('mjpgclient: using basic authentication') + + auth_header = utils.build_basic_header(self._username, self._password) + self.write('GET / HTTP/1.0\r\n\r\nAuthorization: %s\r\n\r\n' % auth_header) + self._seek_http() + + return + + m = re.match('Digest\s*realm="([a-zA-Z0-9\-\s]+)",\s*nonce="([a-zA-Z0-9]+)"', data.strip()) + if m: + logging.debug('mjpgclient: using digest authentication') + + realm, nonce = m.groups() + self._auth_digest_state['realm'] = realm + self._auth_digest_state['nonce'] = nonce + + auth_header = utils.build_digest_header('GET', '/', self._username, self._password, self._auth_digest_state) + self.write('GET / HTTP/1.0\r\n\r\nAuthorization: %s\r\n\r\n' % auth_header) + self._seek_http() + + return + + logging.error('mjpgclient: unknown authentication header: "%s"' % data) + self._seek_content_length() + + def _seek_content_length(self): + if self._check_error(): + return + + self.read_until('Content-Length:', self._on_before_content_length) + + def _on_before_content_length(self, data): + if self._check_error(): + return + + self.read_until('\r\n\r\n', self._on_content_length) + + def _on_content_length(self, data): + if self._check_error(): + return + + matches = re.findall('(\d+)', data) + if not matches: + self._error('could not find content length in mjpg header line "%(header)s"' % { + 'header': data}) + + return + + length = int(matches[0]) + + self.read_bytes(length, self._on_jpg) + + def _on_jpg(self, data): + MjpgClient.last_jpgs[self._camera_id] = data + MjpgClient.last_jpg_moment[self._camera_id] = datetime.datetime.utcnow() + self._seek_content_length() + + +def _garbage_collector(): + logging.debug('running garbage collector for mjpg clients...') + + now = datetime.datetime.utcnow() + for client in MjpgClient.clients.values(): + camera_id = client._camera_id + port = client._port + + # check for last jpg moment timeout + last_jpg_moment = MjpgClient.last_jpg_moment.get(camera_id) + if last_jpg_moment is None: + MjpgClient.last_jpg_moment[camera_id] = now + + continue + + if client.closed(): + continue + + delta = now - last_jpg_moment + delta = delta.days * 86400 + delta.seconds + + if delta > settings.MJPG_CLIENT_TIMEOUT: + logging.error('mjpg client timed out receiving data for camera %(camera_id)s on port %(port)s' % { + 'camera_id': camera_id, 'port': port}) + + motionctl.stop(invalidate=True) # this will close all the mjpg clients + motionctl.start(deferred=True) + + break + + # check for last access timeout + last_access = MjpgClient.last_access.get(camera_id) + if last_access is None: + continue + + delta = now - last_access + delta = delta.days * 86400 + delta.seconds + + if settings.MJPG_CLIENT_IDLE_TIMEOUT and delta > settings.MJPG_CLIENT_IDLE_TIMEOUT: + logging.debug('mjpg client for camera %(camera_id)s on port %(port)s has been idle for %(timeout)s seconds, removing it' % { + 'camera_id': camera_id, 'port': port, 'timeout': settings.MJPG_CLIENT_IDLE_TIMEOUT}) + + client.close() + + continue + + io_loop = ioloop.IOLoop.instance() + io_loop.add_timeout(datetime.timedelta(seconds=settings.MJPG_CLIENT_TIMEOUT), _garbage_collector) + + +def get_jpg(camera_id): + if camera_id not in MjpgClient.clients: + # mjpg client not started yet for this camera + + logging.debug('creating mjpg client for camera %(camera_id)s' % { + 'camera_id': camera_id}) + + camera_config = config.get_camera(camera_id) + if not camera_config['@enabled'] or not utils.local_motion_camera(camera_config): + logging.error('could not start mjpg client for camera id %(camera_id)s: not enabled or not local' % { + 'camera_id': camera_id}) + + return None + + port = camera_config['stream_port'] + username, password = None, None + if camera_config.get('stream_auth_method') > 0: + username, password = camera_config.get('stream_authentication', ':').split(':') + + client = MjpgClient(camera_id, port, username, password) + client.connect() + + MjpgClient.last_access[camera_id] = datetime.datetime.utcnow() + + return MjpgClient.last_jpgs.get(camera_id) + + +def close_all(invalidate=False): + for client in MjpgClient.clients.values(): + client.close() + + if invalidate: + MjpgClient.clients = {} + MjpgClient.last_jpgs = {} + MjpgClient.last_jpg_moment = {} + MjpgClient.last_access = {} + MjpgClient.last_erroneous_close_time = 0 + + +# schedule the garbage collector +io_loop = ioloop.IOLoop.instance() +io_loop.add_timeout(datetime.timedelta(seconds=settings.MJPG_CLIENT_TIMEOUT), _garbage_collector) diff --git a/motioneye/motionctl.py b/motioneye/motionctl.py new file mode 100644 index 0000000..df27ae6 --- /dev/null +++ b/motioneye/motionctl.py @@ -0,0 +1,332 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import errno +import logging +import os.path +import re +import signal +import subprocess +import time + +from tornado.httpclient import HTTPClient, AsyncHTTPClient, HTTPRequest +from tornado.ioloop import IOLoop + +import config +import mjpgclient +import powerctl +import settings +import utils + + +_started = False +_motion_binary_cache = None +_motion_detected = {} + + +def find_motion(): + global _motion_binary_cache + if _motion_binary_cache: + return _motion_binary_cache + + if settings.MOTION_BINARY: + if os.path.exists(settings.MOTION_BINARY): + binary = settings.MOTION_BINARY + + else: + return None + + else: # autodetect motion binary path + try: + binary = subprocess.check_output('which motion', shell=True).strip() + + except subprocess.CalledProcessError: # not found + return None + + try: + help = subprocess.check_output(binary + ' -h || true', shell=True) + + except subprocess.CalledProcessError: # not found + return None + + result = re.findall('^motion Version ([^,]+)', help) + version = result and result[0] or '' + + _motion_binary_cache = (binary, version) + + return _motion_binary_cache + + +def _disable_initial_motion_detection(): + for camera_id in config.get_camera_ids(): + camera_config = config.get_camera(camera_id) + if not utils.local_motion_camera(camera_config): + continue + + if not camera_config['@motion_detection']: + logging.debug('motion detection disabled by config for camera with id %s' % camera_id) + set_motion_detection(camera_id, False) + + +def start(deferred=False): + if deferred: + return IOLoop.instance().add_callback(start, deferred=False) + + global _started + + _started = True + + enabled_local_motion_cameras = config.get_enabled_local_motion_cameras() + if running() or not enabled_local_motion_cameras: + return + + logging.debug('starting motion') + + program = find_motion() + if not program: + raise Exception('motion executable could not be found') + + program, version = program # @UnusedVariable + + logging.debug('using motion binary "%s"' % program) + + motion_config_path = os.path.join(settings.CONF_PATH, 'motion.conf') + motion_log_path = os.path.join(settings.LOG_PATH, 'motion.log') + motion_pid_path = os.path.join(settings.RUN_PATH, 'motion.pid') + + args = [program, + '-c', motion_config_path, + '-n', + '-d'] + + if settings.LOG_LEVEL == logging.DEBUG: + args.append('9') + + else: + args.append('1') + + log_file = open(motion_log_path, 'w') + + process = subprocess.Popen(args, stdout=log_file, stderr=log_file, close_fds=True, cwd=settings.CONF_PATH) + + # wait 2 seconds to see that the process has successfully started + for i in xrange(20): # @UnusedVariable + time.sleep(0.1) + exit_code = process.poll() + if exit_code is not None and exit_code != 0: + raise Exception('motion failed to start') + + pid = process.pid + + # write the pid to file + with open(motion_pid_path, 'w') as f: + f.write(str(pid) + '\n') + + _disable_initial_motion_detection() + + # if mjpg client idle timeout is disabled, create mjpg clients for all cameras by default + if not settings.MJPG_CLIENT_IDLE_TIMEOUT: + logging.debug('creating default mjpg clients for local cameras') + for camera in enabled_local_motion_cameras: + mjpgclient.get_jpg(camera['@id']) + + +def stop(invalidate=False): + global _started + + _started = False + + if not running(): + return + + logging.debug('stopping motion') + + mjpgclient.close_all(invalidate=invalidate) + + pid = _get_pid() + if pid is not None: + try: + # send the TERM signal once + os.kill(pid, signal.SIGTERM) + + # wait 5 seconds for the process to exit + for i in xrange(50): # @UnusedVariable + os.waitpid(pid, os.WNOHANG) + time.sleep(0.1) + + # send the KILL signal once + os.kill(pid, signal.SIGKILL) + + # wait 2 seconds for the process to exit + for i in xrange(20): # @UnusedVariable + time.sleep(0.1) + os.waitpid(pid, os.WNOHANG) + + # the process still did not exit + if settings.ENABLE_REBOOT: + logging.error('could not terminate the motion process') + powerctl.reboot() + + else: + raise Exception('could not terminate the motion process') + + except OSError as e: + if e.errno not in (errno.ESRCH, errno.ECHILD): + raise + + +def running(): + pid = _get_pid() + if pid is None: + return False + + try: + os.waitpid(pid, os.WNOHANG) + os.kill(pid, 0) + + # the process is running + return True + + except OSError as e: + if e.errno not in (errno.ESRCH, errno.ECHILD): + raise + + return False + + +def started(): + return _started + + +def get_motion_detection(camera_id): + thread_id = camera_id_to_thread_id(camera_id) + if thread_id is None: + return logging.error('could not find thread id for camera with id %s' % camera_id) + + url = 'http://127.0.0.1:7999/%(id)s/detection/status' % {'id': thread_id} + + request = HTTPRequest(url, connect_timeout=5, request_timeout=5) + http_client = HTTPClient() + try: + response = http_client.fetch(request) + if response.error: + raise response.error + + except Exception as e: + logging.error('failed to get motion detection status for camera with id %(id)s: %(msg)s' % { + 'id': camera_id, + 'msg': unicode(e)}) + + return None + + enabled = bool(response.body.lower().count('active')) + + logging.debug('motion detection is %(what)s for camera with id %(id)s' % { + 'what': ['disabled', 'enabled'][enabled], + 'id': camera_id}) + + return enabled + + +def set_motion_detection(camera_id, enabled): + thread_id = camera_id_to_thread_id(camera_id) + if thread_id is None: + return logging.error('could not find thread id for camera with id %s' % camera_id) + + if not enabled: + _motion_detected[camera_id] = False + + logging.debug('%(what)s motion detection for camera with id %(id)s' % { + 'what': ['disabling', 'enabling'][enabled], + 'id': camera_id}) + + url = 'http://127.0.0.1:7999/%(id)s/detection/%(enabled)s' % { + 'id': thread_id, + 'enabled': ['pause', 'start'][enabled]} + + def on_response(response): + if response.error: + logging.error('failed to %(what)s motion detection for camera with id %(id)s: %(msg)s' % { + 'what': ['disable', 'enable'][enabled], + 'id': camera_id, + 'msg': utils.pretty_http_error(response)}) + + else: + logging.debug('successfully %(what)s motion detection for camera with id %(id)s' % { + 'what': ['disabled', 'enabled'][enabled], + 'id': camera_id}) + + request = HTTPRequest(url, connect_timeout=4, request_timeout=4) + http_client = AsyncHTTPClient() + http_client.fetch(request, on_response) + + +def is_motion_detected(camera_id): + return _motion_detected.get(camera_id, False) + + +def set_motion_detected(camera_id, motion_detected): + if motion_detected: + logging.debug('marking motion detected for camera with id %s' % camera_id) + + else: + logging.debug('clearing motion detected for camera with id %s' % camera_id) + + _motion_detected[camera_id] = motion_detected + + +def camera_id_to_thread_id(camera_id): + # find the corresponding thread_id + # (which can be different from camera_id) + camera_ids = config.get_camera_ids() + thread_id = 0 + for cid in camera_ids: + camera_config = config.get_camera(cid) + if utils.local_motion_camera(camera_config): + thread_id += 1 + + if cid == camera_id: + return thread_id or None + + return None + + +def thread_id_to_camera_id(thread_id): + # find the corresponding camera_id + # (which can be different from thread_id) + camera_ids = config.get_camera_ids() + tid = 0 + for cid in camera_ids: + camera_config = config.get_camera(cid) + if utils.local_motion_camera(camera_config): + tid += 1 + if tid == thread_id: + return cid + + return None + + +def _get_pid(): + motion_pid_path = os.path.join(settings.RUN_PATH, 'motion.pid') + + # read the pid from file + try: + with open(motion_pid_path, 'r') as f: + return int(f.readline().strip()) + + except (IOError, ValueError): + return None diff --git a/motioneye/ordereddict.py b/motioneye/ordereddict.py new file mode 100644 index 0000000..0874135 --- /dev/null +++ b/motioneye/ordereddict.py @@ -0,0 +1,258 @@ +# Backport of OrderedDict() class that runs on Python 2.4, 2.5, 2.6, 2.7 and pypy. +# Passes Python2.7's test suite and incorporates all the latest updates. + +try: + from thread import get_ident as _get_ident +except ImportError: + from dummy_thread import get_ident as _get_ident + +try: + from _abcoll import KeysView, ValuesView, ItemsView +except ImportError: + pass + + +class OrderedDict(dict): + 'Dictionary that remembers insertion order' + # An inherited dict maps keys to values. + # The inherited dict provides __getitem__, __len__, __contains__, and get. + # The remaining methods are order-aware. + # Big-O running times for all methods are the same as for regular dictionaries. + + # The internal self.__map dictionary maps keys to links in a doubly linked list. + # The circular doubly linked list starts and ends with a sentinel element. + # The sentinel element never gets deleted (this simplifies the algorithm). + # Each link is stored as a list of length three: [PREV, NEXT, KEY]. + + def __init__(self, *args, **kwds): + '''Initialize an ordered dictionary. Signature is the same as for + regular dictionaries, but keyword arguments are not recommended + because their insertion order is arbitrary. + + ''' + if len(args) > 1: + raise TypeError('expected at most 1 arguments, got %d' % len(args)) + try: + self.__root + except AttributeError: + self.__root = root = [] # sentinel node + root[:] = [root, root, None] + self.__map = {} + self.__update(*args, **kwds) + + def __setitem__(self, key, value, dict_setitem=dict.__setitem__): + 'od.__setitem__(i, y) <==> od[i]=y' + # Setting a new item creates a new link which goes at the end of the linked + # list, and the inherited dictionary is updated with the new key/value pair. + if key not in self: + root = self.__root + last = root[0] + last[1] = root[0] = self.__map[key] = [last, root, key] + dict_setitem(self, key, value) + + def __delitem__(self, key, dict_delitem=dict.__delitem__): + 'od.__delitem__(y) <==> del od[y]' + # Deleting an existing item uses self.__map to find the link which is + # then removed by updating the links in the predecessor and successor nodes. + dict_delitem(self, key) + link_prev, link_next, key = self.__map.pop(key) + link_prev[1] = link_next + link_next[0] = link_prev + + def __iter__(self): + 'od.__iter__() <==> iter(od)' + root = self.__root + curr = root[1] + while curr is not root: + yield curr[2] + curr = curr[1] + + def __reversed__(self): + 'od.__reversed__() <==> reversed(od)' + root = self.__root + curr = root[0] + while curr is not root: + yield curr[2] + curr = curr[0] + + def clear(self): + 'od.clear() -> None. Remove all items from od.' + try: + for node in self.__map.itervalues(): + del node[:] + root = self.__root + root[:] = [root, root, None] + self.__map.clear() + except AttributeError: + pass + dict.clear(self) + + def popitem(self, last=True): + '''od.popitem() -> (k, v), return and remove a (key, value) pair. + Pairs are returned in LIFO order if last is true or FIFO order if false. + + ''' + if not self: + raise KeyError('dictionary is empty') + root = self.__root + if last: + link = root[0] + link_prev = link[0] + link_prev[1] = root + root[0] = link_prev + else: + link = root[1] + link_next = link[1] + root[1] = link_next + link_next[0] = root + key = link[2] + del self.__map[key] + value = dict.pop(self, key) + return key, value + + # -- the following methods do not depend on the internal structure -- + + def keys(self): + 'od.keys() -> list of keys in od' + return list(self) + + def values(self): + 'od.values() -> list of values in od' + return [self[key] for key in self] + + def items(self): + 'od.items() -> list of (key, value) pairs in od' + return [(key, self[key]) for key in self] + + def iterkeys(self): + 'od.iterkeys() -> an iterator over the keys in od' + return iter(self) + + def itervalues(self): + 'od.itervalues -> an iterator over the values in od' + for k in self: + yield self[k] + + def iteritems(self): + 'od.iteritems -> an iterator over the (key, value) items in od' + for k in self: + yield (k, self[k]) + + def update(*args, **kwds): #@NoSelf + '''od.update(E, **F) -> None. Update od from dict/iterable E and F. + + If E is a dict instance, does: for k in E: od[k] = E[k] + If E has a .keys() method, does: for k in E.keys(): od[k] = E[k] + Or if E is an iterable of items, does: for k, v in E: od[k] = v + In either case, this is followed by: for k, v in F.items(): od[k] = v + + ''' + if len(args) > 2: + raise TypeError('update() takes at most 2 positional ' + 'arguments (%d given)' % (len(args),)) + elif not args: + raise TypeError('update() takes at least 1 argument (0 given)') + self = args[0] + # Make progressively weaker assumptions about "other" + other = () + if len(args) == 2: + other = args[1] + if isinstance(other, dict): + for key in other: + self[key] = other[key] + elif hasattr(other, 'keys'): + for key in other.keys(): + self[key] = other[key] + else: + for key, value in other: + self[key] = value + for key, value in kwds.items(): + self[key] = value + + __update = update # let subclasses override update without breaking __init__ + + __marker = object() + + def pop(self, key, default=__marker): + '''od.pop(k[,d]) -> v, remove specified key and return the corresponding value. + If key is not found, d is returned if given, otherwise KeyError is raised. + + ''' + if key in self: + result = self[key] + del self[key] + return result + if default is self.__marker: + raise KeyError(key) + return default + + def setdefault(self, key, default=None): + 'od.setdefault(k[,d]) -> od.get(k,d), also set od[k]=d if k not in od' + if key in self: + return self[key] + self[key] = default + return default + + def __repr__(self, _repr_running={}): + 'od.__repr__() <==> repr(od)' + call_key = id(self), _get_ident() + if call_key in _repr_running: + return '...' + _repr_running[call_key] = 1 + try: + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, self.items()) + finally: + del _repr_running[call_key] + + def __reduce__(self): + 'Return state information for pickling' + items = [[k, self[k]] for k in self] + inst_dict = vars(self).copy() + for k in vars(OrderedDict()): + inst_dict.pop(k, None) + if inst_dict: + return (self.__class__, (items,), inst_dict) + return self.__class__, (items,) + + def copy(self): + 'od.copy() -> a shallow copy of od' + return self.__class__(self) + + @classmethod + def fromkeys(cls, iterable, value=None): + '''OD.fromkeys(S[, v]) -> New ordered dictionary with keys from S + and values equal to v (which defaults to None). + + ''' + d = cls() + for key in iterable: + d[key] = value + return d + + def __eq__(self, other): + '''od.__eq__(y) <==> od==y. Comparison to another OD is order-sensitive + while comparison to a regular mapping is order-insensitive. + + ''' + if isinstance(other, OrderedDict): + return len(self)==len(other) and self.items() == other.items() + return dict.__eq__(self, other) + + def __ne__(self, other): + return not self == other + + # -- the following methods are only used in Python 2.7 -- + + def viewkeys(self): + "od.viewkeys() -> a set-like object providing a view on od's keys" + return KeysView(self) + + def viewvalues(self): + "od.viewvalues() -> an object providing a view on od's values" + return ValuesView(self) + + def viewitems(self): + "od.viewitems() -> a set-like object providing a view on od's items" + return ItemsView(self) diff --git a/motioneye/powerctl.py b/motioneye/powerctl.py new file mode 100644 index 0000000..3b82ab7 --- /dev/null +++ b/motioneye/powerctl.py @@ -0,0 +1,78 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import os +import subprocess + + +def _find_prog(prog): + try: + return subprocess.check_output('which %s' % prog, shell=True).strip() + + except subprocess.CalledProcessError: # not found + return None + + +def _exec_prog(prog): + logging.info('executing "%s"' % prog) + + return os.system(prog) == 0 + + +def shut_down(): + logging.info('shutting down') + + prog = _find_prog('poweroff') + if prog: + return _exec_prog(prog) + + prog = _find_prog('shutdown') + if prog: + return _exec_prog(prog + ' -h now') + + prog = _find_prog('systemctl') + if prog: + return _exec_prog(prog + ' poweroff') + + prog = _find_prog('init') + if prog: + return _exec_prog(prog + ' 0') + + return False + + +def reboot(): + logging.info('rebooting') + + prog = _find_prog('reboot') + if prog: + return _exec_prog(prog) + + prog = _find_prog('shutdown') + if prog: + return _exec_prog(prog + ' -r now') + + prog = _find_prog('systemctl') + if prog: + return _exec_prog(prog + ' reboot') + + prog = _find_prog('init') + if prog: + return _exec_prog(prog + ' 6') + + return False diff --git a/motioneye/remote.py b/motioneye/remote.py new file mode 100644 index 0000000..a10599a --- /dev/null +++ b/motioneye/remote.py @@ -0,0 +1,646 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import functools +import json +import logging +import re + +from tornado.httpclient import AsyncHTTPClient, HTTPRequest + +import settings +import utils + +_DOUBLE_SLASH_REGEX = re.compile('//+') + + +def _make_request(scheme, host, port, username, password, uri, method='GET', data=None, query=None, timeout=None): + uri = _DOUBLE_SLASH_REGEX.sub('/', uri) + url = '%(scheme)s://%(host)s%(port)s%(uri)s' % { + 'scheme': scheme, + 'host': host, + 'port': ':' + str(port) if port else '', + 'uri': uri or ''} + + query = dict(query or {}) + query['_username'] = username or '' + query['_admin'] = 'true' # always use the admin account + + if url.count('?'): + url += '&' + + else: + url += '?' + + url += '&'.join([(n + '=' + v) for (n, v) in query.iteritems()]) + url += '&_signature=' + utils.compute_signature(method, url, data, password) + + if timeout is None: + timeout = settings.REMOTE_REQUEST_TIMEOUT + + return HTTPRequest(url, method, body=data, connect_timeout=timeout, request_timeout=timeout) + + +def _callback_wrapper(callback): + @functools.wraps(callback) + def wrapper(response): + try: + decoded = json.loads(response.body) + if decoded['error'] == 'unauthorized': + response.error = 'Authentication Error' + + elif decoded['error']: + response.error = decoded['error'] + + except: + pass + + return callback(response) + + return wrapper + + +def pretty_camera_url(local_config, camera=True): + scheme = local_config.get('@scheme', local_config.get('scheme')) or 'http' + host = local_config.get('@host', local_config.get('host')) + port = local_config.get('@port', local_config.get('port')) + uri = local_config.get('@uri', local_config.get('uri')) or '' + + url = scheme + '://' + host + if port and str(port) not in ['80', '443']: + url += ':' + str(port) + + if uri: + url += uri + + if url.endswith('/'): + url = url[:-1] + + if camera: + if camera is True: + url += '/config/' + str(local_config.get('@remote_camera_id', local_config.get('remote_camera_id'))) + + else: + url += '/config/' + str(camera) + + return url + + +def _remote_params(local_config): + return ( + local_config.get('@scheme', local_config.get('scheme')) or 'http', + local_config.get('@host', local_config.get('host')), + local_config.get('@port', local_config.get('port')), + local_config.get('@username', local_config.get('username')), + local_config.get('@password', local_config.get('password')), + local_config.get('@uri', local_config.get('uri')) or '', + local_config.get('@remote_camera_id', local_config.get('remote_camera_id'))) + + +def list(local_config, callback): + scheme, host, port, username, password, uri, _ = _remote_params(local_config) + + logging.debug('listing remote cameras on %(url)s' % { + 'url': pretty_camera_url(local_config, camera=False)}) + + request = _make_request(scheme, host, port, username, password, uri + '/config/list/') + + def on_response(response): + if response.error: + logging.error('failed to list remote cameras on %(url)s: %(msg)s' % { + 'url': pretty_camera_url(local_config, camera=False), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + try: + response = json.loads(response.body) + + except Exception as e: + logging.error('failed to decode json answer from %(url)s: %(msg)s' % { + 'url': pretty_camera_url(local_config, camera=False), + 'msg': unicode(e)}) + + return callback(error=unicode(e)) + + cameras = response['cameras'] + + # filter out simple mjpeg cameras + cameras = [c for c in cameras if c['proto'] != 'mjpeg'] + + callback(cameras) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def get_config(local_config, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('getting config for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + request = _make_request(scheme, host, port, username, password, uri + '/config/%(id)s/get/' % {'id': camera_id}) + + def on_response(response): + if response.error: + logging.error('failed to get config for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + try: + response = json.loads(response.body) + + except Exception as e: + logging.error('failed to decode json answer from %(url)s: %(msg)s' % { + 'url': pretty_camera_url(local_config), + 'msg': unicode(e)}) + + return callback(error=unicode(e)) + + response['host'] = host + response['port'] = port + + callback(response) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def set_config(local_config, ui_config, callback): + scheme = local_config.get('@scheme', local_config.get('scheme')) + host = local_config.get('@host', local_config.get('host')) + port = local_config.get('@port', local_config.get('port')) + username = local_config.get('@username', local_config.get('username')) + password = local_config.get('@password', local_config.get('password')) + uri = local_config.get('@uri', local_config.get('uri')) or '' + camera_id = local_config.get('@remote_camera_id', local_config.get('remote_camera_id')) + + logging.debug('setting config for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + ui_config = json.dumps(ui_config) + + request = _make_request(scheme, host, port, username, password, uri + '/config/%(id)s/set/' % {'id': camera_id}, method='POST', data=ui_config) + + def on_response(response): + if response.error: + logging.error('failed to set config for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + callback() + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def set_preview(local_config, controls, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('setting preview for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + data = json.dumps(controls) + + request = _make_request(scheme, host, port, username, password, uri + '/config/%(id)s/set_preview/' % {'id': camera_id}, method='POST', data=data) + + def on_response(response): + if response.error: + logging.error('failed to set preview for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + callback() + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def get_current_picture(local_config, width, height, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('getting current picture for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + query = {} + + if width: + query['width'] = str(width) + + if height: + query['height'] = str(height) + + request = _make_request(scheme, host, port, username, password, uri + '/picture/%(id)s/current/' % {'id': camera_id}, query=query) + + def on_response(response): + motion_detected = False + + cookies = response.headers.get('Set-Cookie') + if cookies: + cookies = cookies.split(';') + cookies = [[i.strip() for i in c.split('=')] for c in cookies] + cookies = dict([c for c in cookies if len(c) == 2]) + motion_detected = cookies.get('motion_detected_' + str(camera_id)) == 'true' + + if response.error: + logging.error('failed to get current picture for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + callback(motion_detected, response.body) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def list_media(local_config, media_type, prefix, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('getting media list for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + query = {} + if prefix is not None: + query['prefix'] = prefix + + # timeout here is 10 times larger than usual - we expect a big delay when fetching the media list + request = _make_request(scheme, host, port, username, password, uri + '/%(media_type)s/%(id)s/list/' % { + 'id': camera_id, 'media_type': media_type}, query=query, timeout=10 * settings.REMOTE_REQUEST_TIMEOUT) + + def on_response(response): + if response.error: + logging.error('failed to get media list for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + try: + response = json.loads(response.body) + + except Exception as e: + logging.error('failed to decode json answer from %(url)s: %(msg)s' % { + 'url': pretty_camera_url(local_config), + 'msg': unicode(e)}) + + return callback(error=unicode(e)) + + return callback(response) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def get_media_content(local_config, filename, media_type, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('downloading file %(filename)s of remote camera %(id)s on %(url)s' % { + 'filename': filename, + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + uri += '/%(media_type)s/%(id)s/download/%(filename)s' % { + 'media_type': media_type, + 'id': camera_id, + 'filename': filename} + + # timeout here is 10 times larger than usual - we expect a big delay when fetching the media list + request = _make_request(scheme, host, port, username, password, uri, timeout=10 * settings.REMOTE_REQUEST_TIMEOUT) + + def on_response(response): + if response.error: + logging.error('failed to download file %(filename)s of remote camera %(id)s on %(url)s: %(msg)s' % { + 'filename': filename, + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + return callback(response.body) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def make_zipped_content(local_config, media_type, group, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('preparing zip file for group %(group)s of remote camera %(id)s on %(url)s' % { + 'group': group, + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + prepare_uri = uri + '/%(media_type)s/%(id)s/zipped/%(group)s/' % { + 'media_type': media_type, + 'id': camera_id, + 'group': group} + + # timeout here is 100 times larger than usual - we expect a big delay + request = _make_request(scheme, host, port, username, password, prepare_uri, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT) + + def on_response(response): + if response.error: + logging.error('failed to prepare zip file for group %(group)s of remote camera %(id)s on %(url)s: %(msg)s' % { + 'group': group, + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + try: + key = json.loads(response.body)['key'] + + except Exception as e: + logging.error('failed to decode json answer from %(url)s: %(msg)s' % { + 'url': pretty_camera_url(local_config), + 'msg': unicode(e)}) + + return callback(error=unicode(e)) + + callback({'key': key}) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def get_zipped_content(local_config, media_type, key, group, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('downloading zip file for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + request = _make_request(scheme, host, port, username, password, uri + '/%(media_type)s/%(id)s/zipped/%(group)s/?key=%(key)s' % { + 'media_type': media_type, + 'group': group, + 'id': camera_id, + 'key': key}, + timeout=10 * settings.REMOTE_REQUEST_TIMEOUT) + + def on_response(response): + if response.error: + logging.error('failed to download zip file for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + callback({ + 'data': response.body, + 'content_type': response.headers.get('Content-Type'), + 'content_disposition': response.headers.get('Content-Disposition') + }) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def make_timelapse_movie(local_config, framerate, interval, group, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('making timelapse movie for group %(group)s of remote camera %(id)s with rate %(framerate)s/%(int)s on %(url)s' % { + 'group': group, + 'id': camera_id, + 'framerate': framerate, + 'int': interval, + 'url': pretty_camera_url(local_config)}) + + uri += '/picture/%(id)s/timelapse/%(group)s/?interval=%(int)s&framerate=%(framerate)s' % { + 'id': camera_id, + 'int': interval, + 'framerate': framerate, + 'group': group} + + request = _make_request(scheme, host, port, username, password, uri, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT) + + def on_response(response): + if response.error: + logging.error('failed to make timelapse movie for group %(group)s of remote camera %(id)s with rate %(framerate)s/%(int)s on %(url)s: %(msg)s' % { + 'group': group, + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'int': interval, + 'framerate': framerate, + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + try: + response = json.loads(response.body) + + except Exception as e: + logging.error('failed to decode json answer from %(url)s: %(msg)s' % { + 'url': pretty_camera_url(local_config), + 'msg': unicode(e)}) + + return callback(error=unicode(e)) + + callback(response) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def check_timelapse_movie(local_config, group, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('checking timelapse movie status for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + request = _make_request(scheme, host, port, username, password, uri + '/picture/%(id)s/timelapse/%(group)s/?check=true' % { + 'id': camera_id, + 'group': group}) + + def on_response(response): + if response.error: + logging.error('failed to check timelapse movie status for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + try: + response = json.loads(response.body) + + except Exception as e: + logging.error('failed to decode json answer from %(url)s: %(msg)s' % { + 'url': pretty_camera_url(local_config), + 'msg': unicode(e)}) + + return callback(error=unicode(e)) + + callback(response) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def get_timelapse_movie(local_config, key, group, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('downloading timelapse movie for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + request = _make_request(scheme, host, port, username, password, uri + '/picture/%(id)s/timelapse/%(group)s/?key=%(key)s' % { + 'id': camera_id, + 'group': group, + 'key': key}, + timeout=10 * settings.REMOTE_REQUEST_TIMEOUT) + + def on_response(response): + if response.error: + logging.error('failed to download timelapse movie for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + callback({ + 'data': response.body, + 'content_type': response.headers.get('Content-Type'), + 'content_disposition': response.headers.get('Content-Disposition') + }) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def get_media_preview(local_config, filename, media_type, width, height, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('getting file preview for %(filename)s of remote camera %(id)s on %(url)s' % { + 'filename': filename, + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + uri += '/%(media_type)s/%(id)s/preview/%(filename)s' % { + 'media_type': media_type, + 'id': camera_id, + 'filename': filename} + + query = {} + + if width: + query['width'] = str(width) + + if height: + query['height'] = str(height) + + request = _make_request(scheme, host, port, username, password, uri, query=query) + + def on_response(response): + if response.error: + logging.error('failed to get file preview for %(filename)s of remote camera %(id)s on %(url)s: %(msg)s' % { + 'filename': filename, + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + callback(response.body) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def del_media_content(local_config, filename, media_type, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('deleting file %(filename)s of remote camera %(id)s on %(url)s' % { + 'filename': filename, + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + uri += '/%(media_type)s/%(id)s/delete/%(filename)s' % { + 'media_type': media_type, + 'id': camera_id, + 'filename': filename} + + request = _make_request(scheme, host, port, username, password, uri, method='POST', data='{}', timeout=settings.REMOTE_REQUEST_TIMEOUT) + + def on_response(response): + if response.error: + logging.error('failed to delete file %(filename)s of remote camera %(id)s on %(url)s: %(msg)s' % { + 'filename': filename, + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + callback() + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def del_media_group(local_config, group, media_type, callback): + scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) + + logging.debug('deleting group %(group)s of remote camera %(id)s on %(url)s' % { + 'group': group, + 'id': camera_id, + 'url': pretty_camera_url(local_config)}) + + uri += '/%(media_type)s/%(id)s/delete_all/%(group)s/' % { + 'media_type': media_type, + 'id': camera_id, + 'group': group} + + request = _make_request(scheme, host, port, username, password, uri, method='POST', data='{}', timeout=settings.REMOTE_REQUEST_TIMEOUT) + + def on_response(response): + if response.error: + logging.error('failed to delete group %(group)s of remote camera %(id)s on %(url)s: %(msg)s' % { + 'group': group, + 'id': camera_id, + 'url': pretty_camera_url(local_config), + 'msg': utils.pretty_http_error(response)}) + + return callback(error=utils.pretty_http_error(response)) + + callback() + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) diff --git a/motioneye/sendmail.py b/motioneye/sendmail.py new file mode 100755 index 0000000..4844c08 --- /dev/null +++ b/motioneye/sendmail.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import logging +import os +import re +import smtplib +import socket +import sys +import time + +from email import Encoders +from email.mime.text import MIMEText +from email.MIMEMultipart import MIMEMultipart +from email.MIMEBase import MIMEBase +from tornado.ioloop import IOLoop + +import settings + +from motioneye import _configure_settings, _configure_logging, _configure_signals + +_configure_settings() +_configure_signals() +_configure_logging(module='sendmail') + +import config +import mediafiles +import tzctl + + +messages = { + 'motion_start': 'Motion has been detected by camera "%(camera)s/%(hostname)s" at %(moment)s (%(timezone)s).' +} + +subjects = { + 'motion_start': 'motionEye: motion detected by "%(camera)s"' +} + + +def send_mail(server, port, account, password, tls, to, subject, message, files): + conn = smtplib.SMTP(server, port, timeout=getattr(settings, 'SMTP_TIMEOUT', 60)) + if tls: + conn.starttls() + + if account and password: + conn.login(account, password) + + _from = account or 'motioneye@' + socket.gethostname() + + email = MIMEMultipart() + email['Subject'] = subject + email['From'] = _from + email['To'] = ', '.join(to) + email.attach(MIMEText(message)) + + for file in reversed(files): + part = MIMEBase('image', 'jpeg') + with open(file, 'rb') as f: + part.set_payload(f.read()) + + Encoders.encode_base64(part) + part.add_header('Content-Disposition', 'attachment; filename="%s"' % os.path.basename(file)) + email.attach(part) + + if files: + logging.debug('attached %d pictures' % len(files)) + + logging.debug('sending email message') + conn.sendmail(_from, to, email.as_string()) + conn.quit() + + +def make_message(subject, message, camera_id, moment, timespan, callback): + camera_config = config.get_camera(camera_id) + + def on_media_files(media_files): + logging.debug('got media files') + + timestamp = time.mktime(moment.timetuple()) + + media_files = [m for m in media_files if abs(m['timestamp'] - timestamp) < timespan] # filter out non-recent media files + media_files.sort(key=lambda m: m['timestamp'], reverse=True) + media_files = [os.path.join(camera_config['target_dir'], re.sub('^/', '', m['path'])) for m in media_files] + + logging.debug('selected %d pictures' % len(media_files)) + + format_dict = { + 'camera': camera_config['@name'], + 'hostname': socket.gethostname(), + 'moment': moment.strftime('%Y-%m-%d %H:%M:%S'), + } + + if settings.LOCAL_TIME_FILE: + format_dict['timezone'] = tzctl._get_time_zone() + + else: + format_dict['timezone'] = 'local time' + + m = message % format_dict + s = subject % format_dict + s = s.replace('\n', ' ') + + m += '\n\n' + m += 'motionEye.' + + callback(s, m, media_files) + + if not timespan: + return on_media_files([]) + + logging.debug('creating email message') + + time.sleep(timespan) # give motion some time to create motion pictures + mediafiles.list_media(camera_config, media_type='picture', callback=on_media_files) + + +def print_usage(): + print 'Usage: sendmail.py [timespan]' + + +if __name__ == '__main__': + if len(sys.argv) < 10: + print_usage() + sys.exit(-1) + + server = sys.argv[1] + port = int(sys.argv[2]) + account = sys.argv[3] + password = sys.argv[4] + tls = sys.argv[5].lower() == 'true' + to = sys.argv[6] + msg_id = sys.argv[7] + camera_id = sys.argv[8] + moment = sys.argv[9] + try: + timespan = int(sys.argv[10]) + + except: + timespan = 0 + + logging.debug('hello!') + + message = messages.get(msg_id) + subject = subjects.get(msg_id) + if not message or not subject: + logging.error('unknown message id') + sys.exit(-1) + + moment = datetime.datetime.strptime(moment, '%Y-%m-%dT%H:%M:%S') + + logging.debug('server = %s' % server) + logging.debug('port = %s' % port) + logging.debug('account = %s' % account) + logging.debug('password = ******') + logging.debug('server = %s' % server) + logging.debug('tls = %s' % tls) + logging.debug('to = %s' % to) + logging.debug('msg_id = %s' % msg_id) + logging.debug('camera_id = %s' % camera_id) + logging.debug('moment = %s' % moment.strftime('%Y-%m-%d %H:%M:%S')) + logging.debug('smtp timeout = %d' % settings.SMTP_TIMEOUT) + logging.debug('timespan = %d' % timespan) + + if not to: + logging.info('no email address specified') + sys.exit(0) + + to = [t.strip() for t in re.split('[,;| ]', to)] + to = [t for t in to if t] + + io_loop = IOLoop.instance() + + def on_message(subject, message, files): + try: + send_mail(server, port, account, password, tls, to, subject, message, files) + logging.info('email sent') + + except Exception as e: + logging.error('failed to send mail: %s' % e, exc_info=True) + + io_loop.stop() + + def ioloop_timeout(): + io_loop.stop() + + make_message(subject, message, camera_id, moment, timespan, on_message) + + io_loop.add_timeout(datetime.timedelta(seconds=settings.SMTP_TIMEOUT), ioloop_timeout) + io_loop.start() + + logging.debug('bye!') \ No newline at end of file diff --git a/motioneye/server.py b/motioneye/server.py new file mode 100644 index 0000000..76b385b --- /dev/null +++ b/motioneye/server.py @@ -0,0 +1,351 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import multiprocessing +import os.path +import signal +import sys + +from tornado.httpclient import AsyncHTTPClient +from tornado.web import Application + +import handlers +import logging +import settings +import template + + +def load_settings(): + # TODO use optparse +# length = len(sys.argv) - 1 +# for i in xrange(length): +# arg = sys.argv[i + 1] +# +# if not arg.startswith('--'): +# continue +# +# next_arg = None +# if i < length - 1: +# next_arg = sys.argv[i + 2] +# +# name = arg[2:].upper().replace('-', '_') +# +# if hasattr(settings, name): +# curr_value = getattr(settings, name) +# +# if next_arg.lower() == 'debug': +# next_arg = logging.DEBUG +# +# elif next_arg.lower() == 'info': +# next_arg = logging.INFO +# +# elif next_arg.lower() == 'warn': +# next_arg = logging.WARN +# +# elif next_arg.lower() == 'error': +# next_arg = logging.ERROR +# +# elif next_arg.lower() == 'fatal': +# next_arg = logging.FATAL +# +# elif next_arg.lower() == 'true': +# next_arg = True +# +# elif next_arg.lower() == 'false': +# next_arg = False +# +# elif isinstance(curr_value, int): +# next_arg = int(next_arg) +# +# elif isinstance(curr_value, float): +# next_arg = float(next_arg) +# +# setattr(settings, name, next_arg) +# +# else: +# return arg[2:] + + if not os.path.exists(settings.CONF_PATH): + logging.fatal('config directory "%s" does not exist' % settings.CONF_PATH) + sys.exit(-1) + + if not os.path.exists(settings.RUN_PATH): + logging.fatal('pid directory "%s" does not exist' % settings.RUN_PATH) + sys.exit(-1) + + if not os.path.exists(settings.LOG_PATH): + logging.fatal('log directory "%s" does not exist' % settings.LOG_PATH) + sys.exit(-1) + + if not os.path.exists(settings.MEDIA_PATH): + logging.fatal('media directory "%s" does not exist' % settings.MEDIA_PATH) + sys.exit(-1) + + +def configure_signals(): + def bye_handler(signal, frame): + import tornado.ioloop + + logging.info('interrupt signal received, shutting down...') + + # shut down the IO loop if it has been started + ioloop = tornado.ioloop.IOLoop.instance() + ioloop.stop() + + def child_handler(signal, frame): + # this is required for the multiprocessing mechanism to work + multiprocessing.active_children() + + signal.signal(signal.SIGINT, bye_handler) + signal.signal(signal.SIGTERM, bye_handler) + signal.signal(signal.SIGCHLD, child_handler) + + +def configure_logging(module=None): + if module: + format = '%(asctime)s: [{module}] %(levelname)s: %(message)s'.format(module=module) + + else: + format = '%(asctime)s: %(levelname)s: %(message)s' + + logging.basicConfig(filename=None, level=settings.LOG_LEVEL, + format=format, datefmt='%Y-%m-%d %H:%M:%S') + + logging.getLogger('tornado').setLevel(logging.WARN) + + +def configure_tornado(): + AsyncHTTPClient.configure('tornado.curl_httpclient.CurlAsyncHTTPClient', max_clients=16) + + +def test_requirements(): + if os.geteuid() != 0: + if settings.SMB_SHARES: + print('SMB_SHARES require root privileges') + return False + + if settings.ENABLE_REBOOT: + print('reboot requires root privileges') + return False + + try: + import tornado # @UnusedImport + + except ImportError: + logging.fatal('please install tornado version 3.1 or greater') + sys.exit(-1) + + try: + import jinja2 # @UnusedImport + + except ImportError: + logging.fatal('please install jinja2') + sys.exit(-1) + + try: + import PIL.Image # @UnusedImport + + except ImportError: + logging.fatal('please install pillow or PIL') + sys.exit(-1) + + try: + import pycurl # @UnusedImport + + except ImportError: + logging.fatal('please install pycurl') + sys.exit(-1) + + import mediafiles + has_ffmpeg = mediafiles.find_ffmpeg() is not None + + import motionctl + has_motion = motionctl.find_motion() is not None + + import v4l2ctl + has_v4lutils = v4l2ctl.find_v4l2_ctl() is not None + + import smbctl + if settings.SMB_SHARES and smbctl.find_mount_cifs() is None: + logging.fatal('please install cifs-utils') + sys.exit(-1) + + if not has_ffmpeg: + logging.info('ffmpeg not installed') + + if not has_motion: + logging.info('motion not installed') + + if not has_v4lutils: + logging.info('v4l-utils not installed') + + +def start_motion(): + import tornado.ioloop + import config + import motionctl + + ioloop = tornado.ioloop.IOLoop.instance() + + # add a motion running checker + def checker(): + if ioloop._stopped: + return + + if not motionctl.running() and motionctl.started() and config.get_enabled_local_motion_cameras(): + try: + logging.error('motion not running, starting it') + motionctl.start() + + except Exception as e: + logging.error('failed to start motion: %(msg)s' % { + 'msg': unicode(e)}, exc_info=True) + + ioloop.add_timeout(datetime.timedelta(seconds=settings.MOTION_CHECK_INTERVAL), checker) + + motionctl.start() + + ioloop.add_timeout(datetime.timedelta(seconds=settings.MOTION_CHECK_INTERVAL), checker) + + +def start_cleanup(): + import cleanup + + cleanup.start() + logging.info('cleanup started') + + +def start_wsswitch(): + import wsswitch + + wsswitch.start() + logging.info('wsswitch started') + + +def start_thumbnailer(): + import thumbnailer + + thumbnailer.start() + logging.info('thumbnailer started') + + +def run_server(): + import cleanup + import motionctl + import thumbnailer + import tornado.ioloop + import smbctl + + application.listen(settings.PORT, settings.LISTEN) + logging.info('server started') + + tornado.ioloop.IOLoop.instance().start() + + logging.info('server stopped') + + if thumbnailer.running(): + thumbnailer.stop() + logging.info('thumbnailer stopped') + + if cleanup.running(): + cleanup.stop() + logging.info('cleanup stopped') + + if motionctl.running(): + motionctl.stop() + logging.info('motion stopped') + + if settings.SMB_SHARES: + smbctl.umount_all() + logging.info('SMB shares unmounted') + + +def log_request(handler): + if handler.get_status() < 400: + log_method = logging.debug + + elif handler.get_status() < 500: + log_method = logging.warning + + else: + log_method = logging.error + + request_time = 1000.0 * handler.request.request_time() + log_method("%d %s %.2fms", handler.get_status(), + handler._request_summary(), request_time) + + +application = Application( + [ + (r'^/$', handlers.MainHandler), + (r'^/config/main/(?Pset|get)/?$', handlers.ConfigHandler), + (r'^/config/(?P\d+)/(?Pget|set|rem|set_preview)/?$', handlers.ConfigHandler), + (r'^/config/(?Padd|list|backup|restore)/?$', handlers.ConfigHandler), + (r'^/picture/(?P\d+)/(?Pcurrent|list|frame)/?$', handlers.PictureHandler), + (r'^/picture/(?P\d+)/(?Pdownload|preview|delete)/(?P.+?)/?$', handlers.PictureHandler), + (r'^/picture/(?P\d+)/(?Pzipped|timelapse|delete_all)/(?P.+?)/?$', handlers.PictureHandler), + (r'^/movie/(?P\d+)/(?Plist)/?$', handlers.MovieHandler), + (r'^/movie/(?P\d+)/(?Pdownload|preview|delete)/(?P.+?)/?$', handlers.MovieHandler), + (r'^/movie/(?P\d+)/(?Pdelete_all)/(?P.+?)/?$', handlers.MovieHandler), + (r'^/_relay_event/?$', handlers.RelayEventHandler), + (r'^/log/(?P\w+)/?$', handlers.LogHandler), + (r'^/update/?$', handlers.UpdateHandler), + (r'^/power/(?Pshutdown|reboot)/?$', handlers.PowerHandler), + (r'^/version/?$', handlers.VersionHandler), + (r'^/login/?$', handlers.LoginHandler), + (r'^.*$', handlers.NotFoundHandler), + ], + debug=False, + log_function=log_request, + static_path=settings.STATIC_PATH, + static_url_prefix=settings.STATIC_URL +) + +template.add_context('STATIC_URL', settings.STATIC_URL) + + +def main(): + import motioneye + + load_settings() + configure_signals() + configure_logging() + test_requirements() + configure_tornado() + + logging.info('hello! this is motionEye %s' % motioneye.VERSION) + + if settings.SMB_SHARES: + import smbctl + + stop, start = smbctl.update_mounts() # @UnusedVariable + if start: + start_motion() + + else: + start_motion() + + start_cleanup() + start_wsswitch() + + if settings.THUMBNAILER_INTERVAL: + start_thumbnailer() + + run_server() + + logging.info('bye!') diff --git a/motioneye/settings.py b/motioneye/settings.py new file mode 100644 index 0000000..9e7f68b --- /dev/null +++ b/motioneye/settings.py @@ -0,0 +1,99 @@ + +import logging +import os.path +import sys + +import motioneye + +# the root directory of the project +PROJECT_PATH = os.path.dirname(motioneye.__file__) + +# the templates directory +TEMPLATE_PATH = os.path.join(PROJECT_PATH, 'templates') + +# the static files directory +STATIC_PATH = os.path.join(PROJECT_PATH, 'static') + +# static files (.css, .js etc) are served at this root url +STATIC_URL = '/static/' + +# path to the config directory; must be writable +CONF_PATH = [sys.prefix, ''][sys.prefix == '/usr'] + '/etc/motioneye' + +# pid files go here +for d in ['/run', '/var/run', '/tmp', '/var/tmp']: + if os.path.exists(d): + RUN_PATH = d + break + +else: + RUN_PATH = PROJECT_PATH + +# log files go here +for d in ['/log', '/var/log', '/tmp', '/var/tmp']: + if os.path.exists(d): + LOG_PATH = d + break + +else: + LOG_PATH = RUN_PATH + +# default output path for media files +MEDIA_PATH = RUN_PATH + +# path to motion binary (automatically detected if not set) +MOTION_BINARY = None + +# the log level +LOG_LEVEL = logging.INFO + +# IP addresses to listen on +LISTEN = '0.0.0.0' + +# the TCP port to listen on +PORT = 8765 + +# interval in seconds at which motionEye checks the SMB mounts +MOUNT_CHECK_INTERVAL = 300 + +# interval in seconds at which motionEye checks if motion is running +MOTION_CHECK_INTERVAL = 10 + +# interval in seconds at which the janitor is called to remove old pictures and movies +CLEANUP_INTERVAL = 43200 + +# interval in seconds at which the thumbnail mechanism runs (set to 0 to disable) +THUMBNAILER_INTERVAL = 60 + +# timeout in seconds when waiting for response from a remote motionEye server +REMOTE_REQUEST_TIMEOUT = 10 + +# timeout in seconds when waiting for mjpg data from the motion daemon +MJPG_CLIENT_TIMEOUT = 10 + +# timeout in seconds after which an idle mjpg client is removed (set to 0 to disable) +MJPG_CLIENT_IDLE_TIMEOUT = 10 + +# enable SMB shares (requires root) +SMB_SHARES = False + +# the directory where the SMB mounts will be created +SMB_MOUNT_ROOT = '/media' + +# path to a wpa_supplicant.conf file if wifi settings UI is desired +WPA_SUPPLICANT_CONF = None + +# path to a localtime file if time zone settings UI is desired +LOCAL_TIME_FILE = None + +# enables shutdown and rebooting after changing system settings (such as wifi settings or system updates) +ENABLE_REBOOT = False + +# the timeout in seconds to use when talking to a SMTP server +SMTP_TIMEOUT = 60 + +# the time to wait for zip file creation +ZIP_TIMEOUT = 500 + +# enable adding and removing cameras from UI +ADD_REMOVE_CAMERAS = True diff --git a/motioneye/smbctl.py b/motioneye/smbctl.py new file mode 100644 index 0000000..12e4505 --- /dev/null +++ b/motioneye/smbctl.py @@ -0,0 +1,230 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import logging +import os +import re +import subprocess +import time + +from tornado import ioloop + +import config +import settings + + +def find_mount_cifs(): + try: + return subprocess.check_output('which mount.cifs', shell=True).strip() + + except subprocess.CalledProcessError: # not found + return None + + +def make_mount_point(server, share, username): + server = re.sub('[^a-zA-Z0-9]', '_', server).lower() + share = re.sub('[^a-zA-Z0-9]', '_', share).lower() + + if username: + username = re.sub('[^a-zA-Z0-9]', '_', username).lower() + mount_point = os.path.join(settings.SMB_MOUNT_ROOT, 'motioneye_%s_%s_%s' % (server, share, username)) + + else: + mount_point = os.path.join(settings.SMB_MOUNT_ROOT, 'motioneye_%s_%s' % (server, share)) + + return mount_point + + +def _is_motioneye_mount(mount_point): + mount_point_root = os.path.join(settings.SMB_MOUNT_ROOT, 'motioneye_') + return bool(re.match('^' + mount_point_root + '\w+$', mount_point)) + + +def list_mounts(): + logging.debug('listing smb mounts...') + + mounts = [] + with open('/proc/mounts', 'r') as f: + for line in f: + line = line.strip() + if not line: + continue + parts = line.split() + if len(parts) < 4: + continue + + target = parts[0] + mount_point = parts[1] + fstype = parts[2] + opts = ' '.join(parts[3:]) + + if fstype != 'cifs': + continue + + if not _is_motioneye_mount(mount_point): + continue + + match = re.match('//([^/]+)/(.+)', target) + if not match: + continue + + if len(match.groups()) != 2: + continue + + server, share = match.groups() + share = share.replace('\\040', ' ') # spaces are reported oddly by /proc/mounts + + match = re.search('username=([\w\s]+)', opts) + if match: + username = match.group(1) + + else: + username = None + + logging.debug('found smb mount "//%s/%s" at "%s"' % (server, share, mount_point)) + + mounts.append({ + 'server': server, + 'share': share, + 'username': username, + 'mount_point': mount_point + }) + + return mounts + + +def mount(server, share, username, password): + mount_point = make_mount_point(server, share, username) + + logging.debug('making sure mount point "%s" exists' % mount_point) + + if not os.path.exists(mount_point): + os.makedirs(mount_point) + + if username: + opts = 'username=%s,password=%s' % (username, password) + sec_types = ['ntlm', 'ntlmv2', 'none'] + + else: + opts = 'guest' + sec_types = ['none', 'ntlm', 'ntlmv2'] + + for sec in sec_types: + actual_opts = opts + ',sec=' + sec + try: + logging.debug('mounting "//%s/%s" at "%s" (sec=%s)' % (server, share, mount_point, sec)) + subprocess.check_call('mount.cifs "//%s/%s" "%s" -o "%s"' % (server, share, mount_point, actual_opts), shell=True) + break + + except subprocess.CalledProcessError: + pass + + else: + logging.error('failed to mount smb share "//%s/%s" at "%s"' % (server, share, mount_point)) + return None + + # test to see if mount point is writable + try: + path = os.path.join(mount_point, '.motioneye_' + str(int(time.time()))) + os.mkdir(path) + os.rmdir(path) + logging.debug('directory at "%s" is writable' % mount_point) + + except: + logging.error('directory at "%s" is not writable' % mount_point) + + return None + + return mount_point + + +def umount(server, share, username): + mount_point = make_mount_point(server, share, username) + logging.debug('unmounting "//%s/%s" from "%s"' % (server, share, mount_point)) + + try: + subprocess.check_call('umount "%s"' % mount_point, shell=True) + + except subprocess.CalledProcessError: + logging.error('failed to unmount smb share "//%s/%s" from "%s"' % (server, share, mount_point)) + + return False + + try: + os.rmdir(mount_point) + + except Exception as e: + logging.error('failed to remove smb mount point "%s": %s' % (mount_point, e)) + + return False + + return True + + +def update_mounts(): + network_shares = config.get_network_shares() + + mounts = list_mounts() + mounts = dict(((m['server'], m['share'], m['username'] or ''), False) for m in mounts) + + should_stop = False # indicates that motion should be stopped immediately + should_start = True # indicates that motion can be started afterwards + for network_share in network_shares: + key = (network_share['server'], network_share['share'], network_share['username'] or '') + if key in mounts: # found + mounts[key] = True + + else: # needs to be mounted + should_stop = True + if not mount(network_share['server'], network_share['share'], network_share['username'], network_share['password']): + should_start = False + + # unmount the no longer necessary mounts + for (server, share, username), required in mounts.items(): + if not required: + umount(server, share, username) + should_stop = True + + return (should_stop, should_start) + + +def umount_all(): + for mount in list_mounts(): + umount(mount['server'], mount['share'], mount['username']) + + +def _check_mounts(): + import motionctl + + logging.debug('checking SMB mounts...') + + stop, start = update_mounts() + if stop: + motionctl.stop() + + if start: + motionctl.start() + + io_loop = ioloop.IOLoop.instance() + io_loop.add_timeout(datetime.timedelta(seconds=settings.MOUNT_CHECK_INTERVAL), _check_mounts) + + +if settings.SMB_SHARES: + # schedule the mount checker + io_loop = ioloop.IOLoop.instance() + io_loop.add_timeout(datetime.timedelta(seconds=settings.MOUNT_CHECK_INTERVAL), _check_mounts) diff --git a/motioneye/static/css/frame.css b/motioneye/static/css/frame.css new file mode 100644 index 0000000..ff0b35f --- /dev/null +++ b/motioneye/static/css/frame.css @@ -0,0 +1,43 @@ + + + /* basic */ + +body { + color: #dddddd; + background-color: #212121; +} + + + /* camera frame */ + +div.camera-frame { + position: relative; + padding: 0px; + margin: 0px; + width: 100%; + height: 100%; +} + +div.camera-container { + height: 100%; + text-align: center; + overflow: hidden; +} + +img.camera { + height: auto; + margin: auto; +} + +img.camera.error, +img.camera.loading { + height: 100% !important; +} + +div.camera-placeholder { + overflow: hidden; +} + +div.camera-progress { + cursor: default; +} diff --git a/motioneye/static/css/jquery.timepicker.css b/motioneye/static/css/jquery.timepicker.css new file mode 100755 index 0000000..ad4665d --- /dev/null +++ b/motioneye/static/css/jquery.timepicker.css @@ -0,0 +1,69 @@ + +.ui-timepicker-wrapper { + overflow-y: auto; + height: 150px; + width: 6.5em; + background: #414141; + border: 1px solid #515151; + -webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2); + -moz-box-shadow:0 5px 10px rgba(0,0,0,0.2); + box-shadow:0 5px 10px rgba(0,0,0,0.2); + outline: none; + z-index: 10001; + margin: 0; +} + +.ui-timepicker-wrapper.ui-timepicker-with-duration { + width: 11em; +} + +.ui-timepicker-list { + margin: 0; + padding: 0; + list-style: none; +} + +.ui-timepicker-duration { + margin-left: 5px; color: #888; +} + +.ui-timepicker-list:hover .ui-timepicker-duration { + color: #888; +} + +.ui-timepicker-list li { + padding: 3px 0 3px 5px; + cursor: pointer; + white-space: nowrap; + color: white; + list-style: none; + font-size: 0.8em; + margin: 0; +} + +.ui-timepicker-list:hover .ui-timepicker-selected { + background: #aaa; color: black; +} + +li.ui-timepicker-selected, +.ui-timepicker-list li:hover, +.ui-timepicker-list .ui-timepicker-selected:hover { + background: #1980EC; color: #fff; +} + +li.ui-timepicker-selected .ui-timepicker-duration, +.ui-timepicker-list li:hover .ui-timepicker-duration { + color: #ccc; +} + +.ui-timepicker-list li.ui-timepicker-disabled, +.ui-timepicker-list li.ui-timepicker-disabled:hover, +.ui-timepicker-list li.ui-timepicker-selected.ui-timepicker-disabled { + color: #888; + cursor: default; +} + +.ui-timepicker-list li.ui-timepicker-disabled:hover, +.ui-timepicker-list li.ui-timepicker-selected.ui-timepicker-disabled { + background: #f2f2f2; +} diff --git a/motioneye/static/css/main.css b/motioneye/static/css/main.css new file mode 100644 index 0000000..c02e29b --- /dev/null +++ b/motioneye/static/css/main.css @@ -0,0 +1,1043 @@ + + + /* basic */ + +* { + padding: 0px; + border: 0px solid black; + margin: 0px; + outline: 0px; + border-spacing: 0px; + border-collapse: separate; +} + +html { + height: 100%; +} + +body { + height: 100%; + color: #dddddd; + font-size: 22px; + background-color: #212121; +} + +select, +input[type=text], +input[type=password], +textarea { + box-sizing: border-box; +} + + + /* fonts */ + +@font-face { + font-family: 'Maven Pro'; + src: url('../fnt/mavenpro-regular-webfont.eot'); + src: url('../fnt/mavenpro-regular-webfont.eot?#iefix') format('embedded-opentype'), + url('../fnt/mavenpro-regular-webfont.woff') format('woff'), + url('../fnt/mavenpro-regular-webfont.ttf') format('truetype'), + url('../fnt/mavenpro-regular-webfont.svg#maven_proregular') format('svg'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'Maven Pro'; + src: url('../fnt/mavenpro-bold-webfont.eot'); + src: url('../fnt/mavenpro-bold-webfont.eot?#iefix') format('embedded-opentype'), + url('../fnt/mavenpro-bold-webfont.woff') format('woff'), + url('../fnt/mavenpro-bold-webfont.ttf') format('truetype'), + url('../fnt/mavenpro-bold-webfont.svg#maven_probold') format('svg'); + font-weight: bold; + font-style: normal; +} + + + /* layout */ + +html { + font-family: 'Maven Pro'; +} + +div.page, +div.header-container { + position: relative; + min-width: 320px; + width: 100%; +} + +div.page { + font-size: 1em; + transition: all 0.5s linear; + min-height: 100%; +} + +div.header { + background-color: rgba(64, 64, 64, 0.5); + box-shadow: 0px 0px 5px rgba(0,0,0,0.3); + top: 0px; + width: 100%; + height: 50px; + position: fixed; + overflow: hidden; + z-index: 10000; +} + +div.header-container { + transition: all 0.5s linear; +} + +div.footer { + position: absolute; + bottom: 5px; + width: 100%; + height: 3em; + font-size: 0.7em; + color: #aaa; + text-align: center; +} + +div.copyright-note { + border-top: 1px solid #333; + padding-top: 0.2em; + margin: 0px 15%; +} + +div.page-container { + transition: all 0.2s linear; + padding: 55px 5px 3em 2%; +} + +div.page-container.stretched { + margin-left: 40%; + padding-left: 5px; +} + + + /* icons & icon buttons */ + +div.button.settings-button { + margin: 1px; + vertical-align: middle; + background-image: url(../img/settings.svg); + width: 48px; + height: 48px; +} + +div.button.logout-button { + margin: 1px; + vertical-align: middle; + background-image: url(../img/logout.svg); + width: 48px; + height: 48px; +} + +body.admin div.logout-button { + display: none; +} + +body.admin div.settings-top-bar.closed div.logout-button { + display: inline-block; +} + +body:not(.admin) div.settings-top-bar div.logout-button { + display: none; +} + +div.button.rem-camera-button { + display: none; + margin: 1px; + vertical-align: middle; + background-image: url(../img/settings.svg); + width: 48px; + height: 48px; + background-position: -48px 0px; +} + +div.settings-top-bar.open div.button.rem-camera-button { + display: inline-block; +} + +div.logo { + float: right; + display: inline-block; + white-space: nowrap; + opacity: 0.86; +} + +span.logo { + color: white; + vertical-align: middle; + font-size: 27px; + font-weight: bold; + position: relative; + top: 3px; +} + +img.logo { + width: 36px; + height: 36px; + padding: 7px 3px; + vertical-align: middle; +} + +img.background-logo { + position: absolute; + width: 30%; + left: 35%; + top: 10em; + opacity: 0.03; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +img.main-loading-progress { + display: block; + margin: auto; + margin-top: 50px; +} + +div.add-camera-message { + text-align: center; + margin-top: 30px; +} + +div.hostname { + vertical-align: middle; + display: inline-block; + font-size: 27px; +} + + + /* settings */ + +div.settings { + background-color: #313131; + position: fixed; + z-index: 1; + top: 50px; + left: 0px; + width: 0px; + bottom: 0px; + transition: all 0.2s linear; + overflow: auto; +} + +div.settings.open { + width: 40%; + min-width: 320px; +} + +body:not(.admin) div.settings { + display: none !important; +} + +div.settings-container { + position: relative; + padding-top: 10px; + display: none; + white-space: nowrap; +} + +div.settings.open div.settings-container { + display: block; +} + +div.settings-progress { + position: absolute; + top: 0px; + width: 0px; + bottom: 0px; + left: 0px; + background-color: #313131; + opacity: 0; + transition: opacity 0.1s linear; +} + +div.settings-top-bar { + position: relative; + display: inline-block; + width: 40%; + height: 50px; +} + +div.settings-top-bar.open { + background-color: #414141; + min-width: 320px; +} + +body:not(.admin) div.settings-top-bar { + display: none !important; +} + +div.settings-top-bar.closed div.apply-button { + display: none !important; +} + +div.settings-section-title { + position: relative; + text-align: right; + background-color: rgba(100, 100, 100, 0.3); + padding: 5px 0.5em 5px 5px; +} + +a.settings-section-title { +} + +table.settings { + width: 100%; + padding: 0.5em 0.5em 1em 0.5em; +} + +td.settings-item-label { + width: 50%; + text-align: right; + padding-right: 5px; +} + +td.settings-item-value { + width: 50%; + text-align: left; + padding-left: 5px; +} + +span.settings-item-label { + font-size: 0.9em; +} + +span.settings-item-unit { + font-size: 0.6em; + padding: 0px 0.2em; +} + +div.settings-item-separator { + height: 1px; + border-top: 1px solid #414141; + margin: 0.5em 1em; +} + +#cameraSelect { + display: none; + padding: 4px 1.5em 4px 4px; + vertical-align: middle; + font-size: 1.1em; + width: auto; + max-width: 35%; +} + +div.apply-button { + position: relative; + display: none; + opacity: 0; + float: right; + width: 4em; + height: 30px; + line-height: 30px; + text-align: center; + margin: 10px; + color: white; + background-color: #FF6F00; + border-radius: 3px; + transition: all 0.1s linear; +} + +div.apply-button:HOVER { + background-color: #FF7D19; +} + +div.apply-button:ACTIVE { + background-color: #F06800; +} + +div.apply-button.progress { + background-color: #FF6F00; +} + +img.apply-progress { + margin-top: 3px; +} + +div.normal-button { + position: relative; + height: 1.5em; + line-height: 1.5em; + text-align: center; + margin: 2px 0px; + color: white; + font-size: 0.9em; + border-radius: 3px; + transition: all 0.1s linear; + width: 7em; +} + +div.update-button, +div.backup-button, +div.restore-button { + background: #317CAD; +} + +div.shut-down-button, +div.reboot-button { + background: #c0392b; +} + +div.update-button:HOVER, +div.backup-button:HOVER, +div.restore-button:HOVER { + background: #3498db; +} + +div.shut-down-button:HOVER, +div.reboot-button:HOVER { + background: #D43F2F; +} + +div.update-button:ACTIVE, +div.backup-button:ACTIVE, +div.restore-button:ACTIVE { + background: #317CAD; +} + +div.shut-down-button:ACTIVE, +div.reboot-button:ACTIVE { + background: #B03427; +} + +div.settings-top-bar.open #cameraSelect { + display: inline; +} + +div.settings-top-bar.open div.logout-button { + display: none; +} + +div.check-box.section { + margin: 0px; + float: left; +} + +div.check-box.section div.check-box-button { + background-color: #515151; +} + +div.check-box.on.section div.check-box-button { + background-color: #317CAD; +} + +div.check-box.on.section:FOCUS div.check-box-button, +div.check-box.on.section:HOVER div.check-box-button { + background-color: #3498db; +} + +input[type=text].working-schedule.number { + width: 50px; +} + +#diskUsageProgressBar { + width: 90%; +} + +div.hidden, +tr.hidden { + display: none !important; +} + +span.help-mark { + display: inline-block; + visibility: hidden; + text-align: center; + background-color: #414141; + color: #3498db; + font-size: 0.75em; + font-family: monospace; + width: 1.2em; + height: 1.2em; + border-radius: 100em; + cursor: pointer; + vertical-align: middle; + position: relative; + top: -0.1em; +} + +div.settings-section-title > span.help-mark { + background-color: #515151; +} + +div.settings-section-title:HOVER > span.help-mark, +tr:HOVER span.help-mark { + visibility: visible; +} + +span.minimize { + display: inline-block; + background-image: url(../img/combo-box-arrow.svg); + background-size: cover; + width: 0.8em; + height: 0.8em; + cursor: pointer; + vertical-align: middle; + position: relative; + top: -0.1em; + transition: transform 0.1s linear; + -webkit-transform: rotate(90deg); + -moz-transform: rotate(90deg); + -ms-transform: rotate(90deg); + -o-transform: rotate(90deg); + transform: rotate(90deg); +} + +span.minimize.open { + -webkit-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -ms-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); +} + + + /* dialogs */ + +table.login-dialog { + margin: auto; + font-size: 22px; /* always bigger, regardless of screen size */ +} + +table.add-camera-dialog { + margin: auto; +} + +table.add-camera-dialog select, +table.add-camera-dialog input[type=text], +table.add-camera-dialog input[type=password] { + width: 17em; +} + +span#cameraMsgLabel { + color: red; + font-size: 0.7em; +} + +div#addCameraInfo { + font-size: 0.7em; + max-width: 33em; +} + +div.media-dialog { +} + +div.media-dialog-groups { + float: left; + width: 11em; + text-align: center; + overflow: auto; + white-space: nowrap; +} + +div.media-dialog-groups.small-screen { + float: none; +} + +div.media-dialog-group-button { + height: 1.5em; + width: 10.5em; + box-sizing: border-box; + line-height: 1.5em; + text-align: center; + margin: 0em 0.2em 0.2em 0.2em; + padding: 0px 0.5em; + background-color: #414141; + color: #3498db; + border-radius: 3px; + transition: all 0.1s linear; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; +} + +div.media-dialog-groups.small-screen div.media-dialog-group-button { + display: inline-block; +} + +div.media-dialog-group-button:HOVER { + background-color: #515151; +} + +div.media-dialog-group-button:ACTIVE { + background-color: #414141; +} + +div.media-dialog-group-button.current { + background-color: #317CAD; + color: white; +} + +div.media-dialog-group-button.current:HOVER { + background-color: #3498db; +} + +div.media-dialog-group-button.current:ACTIVE { + background-color: #317CAD; +} + +div.media-dialog-list { + overflow: auto; + position: relative; +} + +div.media-list-group-title { + background-color: #313131; + font-size: 1.3em; + font-weight: bold; + text-align: center; + padding: 1em 0px 0.2em 0px; +} + +img.media-list-progress { + position: relative; + top: 35%; + display: block; + margin: auto; +} + +div.media-list-entry { + height: 4em; + background-color: #414141; + border-bottom: 1px solid #313131; + cursor: pointer; + transition: background-color 0.1s linear; +} + +div.media-list-entry:HOVER { + background-color: #494949; +} + +div.media-list-entry:ACTIVE { + background-color: #3b3b3b; +} + +img.media-list-preview { + float: left; + height: 3em; + margin: 0.45em; + border: 1px solid #212121; + box-shadow: 1px 1px 6px rgba(0,0,0,0.3); +} + +div.media-list-entry-name { + font-weight: bold; + font-size: 1.3em; + padding: 0.4em 0em; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +div.media-list-entry-details { + font-size: 1em; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +div.media-list-entry-details span.details-moment-short { + display: none; +} + +div.media-list-download-button, +div.media-list-delete-button { + float: right; + clear: right; + height: 1.5em; + width: 5em; + line-height: 1.5em; + text-align: center; + margin: 0px 0.5em; + padding: 0px 0.5em; + color: white; + border-radius: 3px; + transition: all 0.1s linear; +} + +div.media-list-download-button { + margin-top: 0.4em; + margin-bottom: 0.1em; + background: #317CAD; +} + +div.media-list-download-button:HOVER { + background-color: #3498db; +} + +div.media-list-download-button:ACTIVE { + background-color: #317CAD; +} + +div.media-list-delete-button { + margin-top: 0.1em; + margin-bottom: 0.4em; + background: #c0392b; +} + +div.media-list-delete-button:HOVER { + background-color: #D43F2F; +} + +div.media-list-delete-button:ACTIVE { + background-color: #B03427; +} + +div.media-dialog-buttons { + margin: 0.5em 0px 0px 0px; + text-align: center; +} + +div.media-dialog-button { + cursor: pointer; + display: inline-block; + height: 1.5em; + line-height: 1.5em; + text-align: center; + padding: 0px 0.5em; + margin: 0px 5px 0px 0px; + color: white; + background-color: #317CAD; + border-radius: 3px; + transition: all 0.1s linear; +} + +div.media-dialog-button:HOVER { + background-color: #3498db; +} + +div.media-dialog-button:ACTIVE { + background-color: #317CAD; +} + +div.picture-dialog-content { + position: relative; + text-align: center; + min-height: 100px; +} + +div.picture-dialog-prev-arrow, +div.picture-dialog-next-arrow { + position: absolute; + top: 45%; + background-color: rgba(0, 0, 0, 0.6); + background-image: url(../img/arrows.svg); + background-size: cover; + width: 3em; + height: 3em; + border-radius: 0.3em; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: pointer; +} + +div.picture-dialog-prev-arrow { + left: 1em; +} + +div.picture-dialog-next-arrow { + right: 1em; + background-position: -100% 0%; +} + +img.picture-dialog-content { + border: 1px solid #292929; +} + +img.picture-dialog-progress { + position: absolute; + background-color: #313131; + padding: 10px; + border-radius: 10px; + opacity: 0.7; +} + +table.timelapse-dialog select { + width: 10em; +} + +td.timelapse-warning { + font-size: 80%; + display: none; + color: red; + max-width: 20em; + text-align: center; + white-space: normal; + padding-bottom: 1em; +} + +div.media-dialog-delete-all-button { + margin-top: 0.1em; + margin-bottom: 0.4em; + background: #c0392b; +} + +div.media-dialog-delete-all-button:HOVER { + background-color: #D43F2F; +} + +div.media-dialog-delete-all-button:ACTIVE { + background-color: #B03427; +} + +td.login-dialog-error { + color: red; + display: none; +} + + + /* camera frames */ + +div.camera-list { + text-align: center; +} + +div.camera-frame, +div.camera-frame-place-holder { + position: relative; + width: 32%; + text-align: left; + background-color: #313131; + display: inline-block; + padding: 0px 3px 3px 3px; + border-radius: 3px; + transition: all 0.2s, opacity 0s; + margin: 2px; + opacity: 0; + vertical-align: top; +} + +div.camera-frame:only-child, +div.camera-frame-place-holder:only-child { + width: 48%; +} + +div.camera-frame-place-holder { + visibility: hidden; +} + +div.camera-frame.motion-detected { + background-color: #712727; +} + +div.modal-container div.camera-frame { + width: auto; + padding: 0px; + margin: -7px; + background-color: #414141; +} + +div.camera-frame:HOVER { + background-color: #414141; +} + +div.camera-frame.motion-detected:HOVER { + background-color: #8B3636; +} + +div.camera-top-bar { + padding: 3px 0px; + font-size: 20px; + height: 25px; +} + +div.modal-container div.camera-top-bar { + display: none; +} + +span.camera-name { + float: left; + line-height: 25px; +} + +div.camera-buttons { + float: right; +} + +div.camera-button { + display: inline-block; + width: 24px; + height: 24px; + background-image: url(../img/top-bar-buttons.svg); + background-size: cover; + margin-left: 3px; + cursor: pointer; + transition: all 0.1s linear; +} + +div.camera-button.close { + background-position: 0px 0px; +} + +div.camera-button.full-screen { + background-position: -200% 0px; +} + +div.camera-button.configure { + background-position: -100% 0px; +} + +div.camera-button.media-pictures { + background-position: -300% 0px; +} + +div.camera-button.media-movies { + background-position: -400% 0px; +} + +div.camera-container { + position: relative; + padding: 0px; +} + +img.camera { + position: relative; + width: 100%; + display: block; + transition: opacity 0.2s linear; + opacity: 1; + min-height: 160px; +} + +img.camera.error, +img.camera.loading { + opacity: 0; +} + +div.camera-placeholder { + position: absolute; + top: 0px; + right: 0px; + bottom: 0px; + left: 0px; + text-align: center; + transition: opacity 0.2s linear; +} + +img.no-camera { + margin-top: 20%; + width: 30%; + opacity: 0.8; +} + +div.camera-progress { + background: rgba(0, 0, 0, 0.001); /* otherwise IE would not extend this as expected */ + position: absolute; + top: 0px; + right: 0px; + bottom: 0px; + left: 0px; + opacity: 0; + transition: all 0.2s linear; + text-align: center; + cursor: pointer; +} + +div.camera-progress.visible { + opacity: 0.4; +} + +img.camera-progress { + border: 10px solid white; + border-radius: 10px; + position: absolute; + top: 0px; + left: 0px; + bottom: 0px; + right: 0px; + margin: auto; +} + + + /* media queries */ + +@media all and (max-width: 1440px) { + /* smaller screens */ + + body { + font-size: 17px; + } +} + +@media all and (max-width: 1000px) { + /* small screens (mobile devices) */ + + div.page-container.stretched { + margin-left: 0px; + } + + div.settings.open { + box-shadow: 0px 0px 10px rgba(0,0,0,0.5); + background-color: rgba(40, 40, 40, 0.9); + } + + div.hostname { + display: none; + } + + div.media-list-entry-name { + font-size: 1em; + padding: 0.2em 0em 0em 0em; + } + + div.media-list-entry-details { + padding-top: 0.2em; + font-size: 1em; + text-align: center; + white-space: normal; + } + + div.media-list-entry-details span.details-moment { + display: none; + } + + div.media-list-entry-details span.details-moment-short { + display: block; + } +} + +@media all and (max-width: 400px) { + /* very small screens */ + + body { + font-size: 13px; + } + + div.camera-button { + background-size: cover; + width: 24px; + height: 24px; + } +} + +@media all and (max-width: 1900px) { + div.camera-frame, + div.camera-frame-place-holder { + width: 48%; + } +} + +@media all and (max-width: 1200px) { + div.page-container { + padding-left: 1%; + } + + div.camera-frame, + div.camera-frame-place-holder { + width: 98%; + } + + div.camera-frame:only-child, + div.camera-frame-place-holder:only-child { + width: 98%; + } +} diff --git a/motioneye/static/css/ui.css b/motioneye/static/css/ui.css new file mode 100644 index 0000000..f114a8b --- /dev/null +++ b/motioneye/static/css/ui.css @@ -0,0 +1,469 @@ + + /* general */ + +::selection, +::-moz-selection, +::-webkit-selection { + background: #3498db; +} + +option::selection, +option::-moz-selection, +option::-webkit-selection { + background: transparent; +} + +input[type=checkbox].styled { + display: none; +} + +a { + color: #3498db; + text-decoration: inherit; + transition: color 0.1s ease; + cursor: pointer; +} + +a:HOVER { +} + +a:ACTIVE { + color: #317CAD; +} + + + /* buttons */ + +div.button { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: pointer; + display: inline-block; +} + +div.button.dialog { + background-color: #414141; + min-width: 60px; + height: 1.2em; + line-height: 1.2em; + text-align: center; + padding: 0.2em 0.4em; + border: 1px solid #317CAD; + border-radius: 2px; + color: white; + transition: all 0.1s ease; +} + +div.button.dialog.default { + background-color: #317CAD; +} + +div.button.mouse-effect { + opacity: 0.7; + transition: opacity 0.1s ease; +} + +div.button.mouse-effect:HOVER { + opacity: 1; +} + +div.button.mouse-effect:ACTIVE { + opacity: 0.7; +} + + + /* check box */ + +div.check-box { + display: inline-block; + position: relative; + width: 2.5em; + height: 1em; + border: 1px solid #317CAD; + border-radius: 2px; + color: #aaaaaa; + vertical-align: middle; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + margin: 2px; + transition: all 0.2s ease; +} + +div.check-box:FOCUS, +div.check-box:HOVER { + border-color: #3498db; +} + +div.check-box-button { + width: 50%; + height: 100%; + left: 0px; + background-color: #414141; + color: #aaaaaa; + position: absolute; + text-align: center; + line-height: 1em; + transition: all 0.1s ease; +} + +span.check-box-text { + font-size: 0.5em; + font-weight: bold; + vertical-align: top; +} + +div.check-box.on div.check-box-button { + left: 50%; + background-color: #317CAD; + color: white; +} + +div.check-box.on:FOCUS div.check-box-button, +div.check-box.on:HOVER div.check-box-button { + background-color: #3498db; +} + + + /* input boxes */ + +input[type=password].styled, +input[type=text].styled, +textarea.styled { + width: 90%; + border: 1px solid #317CAD; + border-radius: 2px; + background-color: transparent; + padding: 1px; + color: #dddddd; + font-family: inherit; + font-size: 0.8em; + margin: 2px; + transition: all 0.1s ease; + resize: none; + vertical-align: middle; + white-space: nowrap; +} + +input[type=password].styled:FOCUS, +input[type=text].styled:FOCUS { + background-color: #414141; +} + +input[type=password].styled:HOVER, +input[type=password].styled:FOCUS, +input[type=text].styled:HOVER, +input[type=text].styled:FOCUS { + border-color: #3498db; + color: white; +} + +input[type=text].number { + width: 5em; +} + +input[type=text].error, +input[type=password].error, +input[type=file].error, +select.error { + background-image: url(../img/validation-error.svg) !important; + background-position: center right; + background-repeat: no-repeat; +} + +input[type=text].time { + width: 3.5em; +} + +input[readonly] { + border: 1px solid #555 !important; +} + + + /* combo box */ + +select.styled { + -webkit-appearance: none; + appearance: none; + width: 90%; + border: 1px solid #317CAD; + border-radius: 2px; + background-color: transparent; + padding: 1px 1.25em 1px 1px; + color: #dddddd; + font-family: inherit; + font-size: 0.8em; + margin: 2px; + background-image: url(../img/combo-box-arrow.svg); + background-repeat: no-repeat; + background-position: right center; + cursor: pointer; + vertical-align: middle; +} + +select.styled:FOCUS { + background-color: #414141; + appearance: auto; +} + +select.styled:HOVER, +select.styled:FOCUS { + border-color: #3498db; + color: white; +} + +.ff select.styled { + background-image: none; + padding-right: 1px; +} + + + /* slider */ + +input[type=text].range { + display: none; +} + +div.slider { + width: 82%; + height: 1.7em; + position: relative; + padding: 0.2em 0px; + margin-left: 5%; +} + +div.slider-labels { + position: relative; + width: 100%; + height: 0.5em; +} + +span.slider-label { + display: inline-block; + width: 20%; + text-align: center; + overflow: visible; + position: absolute; + font-size: 0.5em; +} + +div.slider-bar { + position: relative; + top: 7px; + left: -5px; + width: 100%; +} + +div.slider-bar-inside { + border: 1px solid #317CAD; + height: 3px; + position: relative; + top: 3px; + left: 7px; + transition: all 0.1s ease; +} + +div.slider:HOVER div.slider-bar-inside, +div.slider:FOCUS div.slider-bar-inside { + border-color: #3498db; +} + +div.slider:FOCUS div.slider-bar-inside { + background-color: #414141; +} + +div.slider-cursor { + background-image: url(../img/slider-arrow.svg); + width: 11px; + height: 18px; + position: absolute; + left: 0px; + top: -5px; + cursor: pointer; +} + +div.slider-cursor-label { + font-size: 0.6em; + margin-left: 1.5em; + margin-top: 0.15em; + background: rgba(30, 30, 30, 0.8); + vertical-align: top; + padding: 1px 2px; + border-radius: 3px; + display: none; +} + + + /* progress bar */ + +div.progress-bar-container { + position: relative; + height: 1em; + border: 1px solid #555; + vertical-align: middle; + margin: 0px 0.2em; + text-align: center; + line-height: 1em; +} + +div.progress-bar-fill { + position: absolute; + left: 0px; + top: 0px; + bottom: 0px; + width: 0%; + background-color: #555; + transition: width 0.1s ease; +} + +span.progress-bar-text { + vertical-align: middle; + font-size: 0.8em; + position: relative; +} + + + /* modal dialogs */ + +div.modal-glass { + display: none; + position: fixed; + z-index: 10000; + top: 0px; + right: 0px; + bottom: 0px; + left: 0px; + background-color: black; + opacity: 0; +} + +div.modal-container { + position: fixed; + display: none; + z-index: 10001; + background-color: #313131; + border-radius: 3px; + opacity: 0; + padding: 5px; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.3); +} + +div.modal-title-bar { + min-height: 1.5em; + line-height: 1.5em; + text-align: center; + position: relative; +} + +span.modal-title { + color: white; + font-size: 1.2em; + line-height: 1.2em; +} + +div.modal-close-button { + position: absolute; + top: 0.2em; + right: 0.3em; + width: 1.1em; + height: 1.1em; + background-image: url(../img/top-bar-buttons.svg); + background-size: cover; + cursor: pointer; +} + +table.modal-buttons-container { + width: 100%; + margin: auto; + text-align: center; + table-layout: fixed; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +table.modal-buttons-container td:not(:FIRST-CHILD) { + padding-left: 5px; +} + +table.modal-buttons-container div.button.dialog { + display: block; +} + +div.modal-progress { + border-radius: 10px; + background-image: url(../img/modal-progress.gif); + width: 64px; + height: 64px; + margin: auto; +} + +td.dialog-item-label { + text-align: right; + padding-right: 5px; +} + +td.dialog-item-value { + text-align: left; + padding-left: 5px; +} + +span.dialog-item-label { + font-size: 0.9em; +} + +div.dialog-item-separator { + height: 1px; + border-top: 1px solid #414141; + margin: 0.5em 1em; +} + + + /* popup message */ + +div.popup-message-container { + position: fixed; + display: none; + z-index: 10002; + background-color: #313131; + border-radius: 3px; + opacity: 0; + padding: 5px; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.3); + top: 60px; +} + +span.popup-message { + +} + +span.popup-message.info { + color: white; +} + +span.popup-message.error { + color: #FF6D55; +} + + + /* media queries */ + +@media all and (max-width: 400px) { + span.modal-title { + font-size: 1.5em; + + } + + div.modal-title-bar { + min-height: 2em; + line-height: 2em; + } + + div.modal-close-button { + background-size: cover; + width: 24px; + height: 24px; + } +} diff --git a/motioneye/static/favicon.ico b/motioneye/static/favicon.ico new file mode 100644 index 0000000..6cb844d Binary files /dev/null and b/motioneye/static/favicon.ico differ diff --git a/motioneye/static/fnt/mavenpro-black-webfont.eot b/motioneye/static/fnt/mavenpro-black-webfont.eot new file mode 100644 index 0000000..7ee4811 Binary files /dev/null and b/motioneye/static/fnt/mavenpro-black-webfont.eot differ diff --git a/motioneye/static/fnt/mavenpro-black-webfont.svg b/motioneye/static/fnt/mavenpro-black-webfont.svg new file mode 100644 index 0000000..b888155 --- /dev/null +++ b/motioneye/static/fnt/mavenpro-black-webfont.svg @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/motioneye/static/fnt/mavenpro-black-webfont.ttf b/motioneye/static/fnt/mavenpro-black-webfont.ttf new file mode 100644 index 0000000..38d7d3f Binary files /dev/null and b/motioneye/static/fnt/mavenpro-black-webfont.ttf differ diff --git a/motioneye/static/fnt/mavenpro-black-webfont.woff b/motioneye/static/fnt/mavenpro-black-webfont.woff new file mode 100644 index 0000000..6ddfe8c Binary files /dev/null and b/motioneye/static/fnt/mavenpro-black-webfont.woff differ diff --git a/motioneye/static/fnt/mavenpro-bold-webfont.eot b/motioneye/static/fnt/mavenpro-bold-webfont.eot new file mode 100644 index 0000000..030d7cb Binary files /dev/null and b/motioneye/static/fnt/mavenpro-bold-webfont.eot differ diff --git a/motioneye/static/fnt/mavenpro-bold-webfont.svg b/motioneye/static/fnt/mavenpro-bold-webfont.svg new file mode 100644 index 0000000..43eb15b --- /dev/null +++ b/motioneye/static/fnt/mavenpro-bold-webfont.svg @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/motioneye/static/fnt/mavenpro-bold-webfont.ttf b/motioneye/static/fnt/mavenpro-bold-webfont.ttf new file mode 100644 index 0000000..2120446 Binary files /dev/null and b/motioneye/static/fnt/mavenpro-bold-webfont.ttf differ diff --git a/motioneye/static/fnt/mavenpro-bold-webfont.woff b/motioneye/static/fnt/mavenpro-bold-webfont.woff new file mode 100644 index 0000000..7f73d1e Binary files /dev/null and b/motioneye/static/fnt/mavenpro-bold-webfont.woff differ diff --git a/motioneye/static/fnt/mavenpro-medium-webfont.eot b/motioneye/static/fnt/mavenpro-medium-webfont.eot new file mode 100644 index 0000000..efb30f5 Binary files /dev/null and b/motioneye/static/fnt/mavenpro-medium-webfont.eot differ diff --git a/motioneye/static/fnt/mavenpro-medium-webfont.svg b/motioneye/static/fnt/mavenpro-medium-webfont.svg new file mode 100644 index 0000000..8e8503b --- /dev/null +++ b/motioneye/static/fnt/mavenpro-medium-webfont.svg @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/motioneye/static/fnt/mavenpro-medium-webfont.ttf b/motioneye/static/fnt/mavenpro-medium-webfont.ttf new file mode 100644 index 0000000..a73e903 Binary files /dev/null and b/motioneye/static/fnt/mavenpro-medium-webfont.ttf differ diff --git a/motioneye/static/fnt/mavenpro-medium-webfont.woff b/motioneye/static/fnt/mavenpro-medium-webfont.woff new file mode 100644 index 0000000..9a0ed4d Binary files /dev/null and b/motioneye/static/fnt/mavenpro-medium-webfont.woff differ diff --git a/motioneye/static/fnt/mavenpro-regular-webfont.eot b/motioneye/static/fnt/mavenpro-regular-webfont.eot new file mode 100644 index 0000000..b41ecdd Binary files /dev/null and b/motioneye/static/fnt/mavenpro-regular-webfont.eot differ diff --git a/motioneye/static/fnt/mavenpro-regular-webfont.svg b/motioneye/static/fnt/mavenpro-regular-webfont.svg new file mode 100644 index 0000000..074ad9f --- /dev/null +++ b/motioneye/static/fnt/mavenpro-regular-webfont.svg @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/motioneye/static/fnt/mavenpro-regular-webfont.ttf b/motioneye/static/fnt/mavenpro-regular-webfont.ttf new file mode 100644 index 0000000..8fb8aac Binary files /dev/null and b/motioneye/static/fnt/mavenpro-regular-webfont.ttf differ diff --git a/motioneye/static/fnt/mavenpro-regular-webfont.woff b/motioneye/static/fnt/mavenpro-regular-webfont.woff new file mode 100644 index 0000000..16f0e00 Binary files /dev/null and b/motioneye/static/fnt/mavenpro-regular-webfont.woff differ diff --git a/motioneye/static/img/apply-progress.gif b/motioneye/static/img/apply-progress.gif new file mode 100644 index 0000000..610f733 Binary files /dev/null and b/motioneye/static/img/apply-progress.gif differ diff --git a/motioneye/static/img/arrows.svg b/motioneye/static/img/arrows.svg new file mode 100644 index 0000000..f1239a7 --- /dev/null +++ b/motioneye/static/img/arrows.svg @@ -0,0 +1,86 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/motioneye/static/img/camera-progress.gif b/motioneye/static/img/camera-progress.gif new file mode 100644 index 0000000..748c0c7 Binary files /dev/null and b/motioneye/static/img/camera-progress.gif differ diff --git a/motioneye/static/img/combo-box-arrow.svg b/motioneye/static/img/combo-box-arrow.svg new file mode 100644 index 0000000..5f57b93 --- /dev/null +++ b/motioneye/static/img/combo-box-arrow.svg @@ -0,0 +1,78 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/motioneye/static/img/logout.svg b/motioneye/static/img/logout.svg new file mode 100644 index 0000000..cda383d --- /dev/null +++ b/motioneye/static/img/logout.svg @@ -0,0 +1,56 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/motioneye/static/img/main-loading-progress.gif b/motioneye/static/img/main-loading-progress.gif new file mode 100644 index 0000000..4f7a78e Binary files /dev/null and b/motioneye/static/img/main-loading-progress.gif differ diff --git a/motioneye/static/img/modal-progress.gif b/motioneye/static/img/modal-progress.gif new file mode 100644 index 0000000..df1657c Binary files /dev/null and b/motioneye/static/img/modal-progress.gif differ diff --git a/motioneye/static/img/motioneye-icon.svg b/motioneye/static/img/motioneye-icon.svg new file mode 100644 index 0000000..93dfd9c --- /dev/null +++ b/motioneye/static/img/motioneye-icon.svg @@ -0,0 +1,56 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/motioneye/static/img/motioneye-logo.svg b/motioneye/static/img/motioneye-logo.svg new file mode 100644 index 0000000..ea469a3 --- /dev/null +++ b/motioneye/static/img/motioneye-logo.svg @@ -0,0 +1,3 @@ + + +image/svg+xml diff --git a/motioneye/static/img/no-camera.svg b/motioneye/static/img/no-camera.svg new file mode 100644 index 0000000..b7f1800 --- /dev/null +++ b/motioneye/static/img/no-camera.svg @@ -0,0 +1,61 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/motioneye/static/img/no-preview.svg b/motioneye/static/img/no-preview.svg new file mode 100644 index 0000000..17c39f3 --- /dev/null +++ b/motioneye/static/img/no-preview.svg @@ -0,0 +1,74 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/motioneye/static/img/settings.svg b/motioneye/static/img/settings.svg new file mode 100644 index 0000000..c11c28e --- /dev/null +++ b/motioneye/static/img/settings.svg @@ -0,0 +1,108 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/motioneye/static/img/slider-arrow.svg b/motioneye/static/img/slider-arrow.svg new file mode 100644 index 0000000..543e0e9 --- /dev/null +++ b/motioneye/static/img/slider-arrow.svg @@ -0,0 +1,78 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/motioneye/static/img/small-progress.gif b/motioneye/static/img/small-progress.gif new file mode 100644 index 0000000..e0be29f Binary files /dev/null and b/motioneye/static/img/small-progress.gif differ diff --git a/motioneye/static/img/top-bar-buttons.svg b/motioneye/static/img/top-bar-buttons.svg new file mode 100644 index 0000000..a10c006 --- /dev/null +++ b/motioneye/static/img/top-bar-buttons.svg @@ -0,0 +1,184 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/motioneye/static/img/validation-error.svg b/motioneye/static/img/validation-error.svg new file mode 100644 index 0000000..6c4d5da --- /dev/null +++ b/motioneye/static/img/validation-error.svg @@ -0,0 +1,82 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/motioneye/static/js/css-browser-selector.js b/motioneye/static/js/css-browser-selector.js new file mode 100644 index 0000000..79759df --- /dev/null +++ b/motioneye/static/js/css-browser-selector.js @@ -0,0 +1,14 @@ +/* +CSS Browser Selector js v0.5.3 (July 2, 2013) + +-- original -- +Rafael Lima (http://rafael.adm.br) +http://rafael.adm.br/css_browser_selector +License: http://creativecommons.org/licenses/by/2.5/ +Contributors: http://rafael.adm.br/css_browser_selector#contributors +-- /original -- + +Fork project: http://code.google.com/p/css-browser-selector/ +Song Hyo-Jin (shj at xenosi.de) +*/ +function css_browser_selector(n){var b=n.toLowerCase(),f=function(c){return b.indexOf(c)>-1},h="gecko",k="webkit",p="safari",j="chrome",d="opera",e="mobile",l=0,a=window.devicePixelRatio?(window.devicePixelRatio+"").replace(".","_"):"1";var i=[(!(/opera|webtv/.test(b))&&/msie\s(\d+)/.test(b)&&(l=RegExp.$1*1))?("ie ie"+l+((l==6||l==7)?" ie67 ie678 ie6789":(l==8)?" ie678 ie6789":(l==9)?" ie6789 ie9m":(l>9)?" ie9m":"")):(/firefox\/(\d+)\.(\d+)/.test(b)&&(re=RegExp))?h+" ff ff"+re.$1+" ff"+re.$1+"_"+re.$2:f("gecko/")?h:f(d)?d+(/version\/(\d+)/.test(b)?" "+d+RegExp.$1:(/opera(\s|\/)(\d+)/.test(b)?" "+d+RegExp.$2:"")):f("konqueror")?"konqueror":f("blackberry")?e+" blackberry":(f(j)||f("crios"))?k+" "+j:f("iron")?k+" iron":!f("cpu os")&&f("applewebkit/")?k+" "+p:f("mozilla/")?h:"",f("android")?e+" android":"",f("tablet")?"tablet":"",f("j2me")?e+" j2me":f("ipad; u; cpu os")?e+" chrome android tablet":f("ipad;u;cpu os")?e+" chromedef android tablet":f("iphone")?e+" ios iphone":f("ipod")?e+" ios ipod":f("ipad")?e+" ios ipad tablet":f("mac")?"mac":f("darwin")?"mac":f("webtv")?"webtv":f("win")?"win"+(f("windows nt 6.0")?" vista":""):f("freebsd")?"freebsd":(f("x11")||f("linux"))?"linux":"",(a!="1")?" retina ratio"+a:"","js portrait"].join(" ");if(window.jQuery&&!window.jQuery.browser){window.jQuery.browser=l?{msie:1,version:l}:{}}return i}(function(j,b){var c=css_browser_selector(navigator.userAgent);var g=j.documentElement;g.className+=" "+c;var a=c.replace(/^\s*|\s*$/g,"").split(/ +/);b.CSSBS=1;for(var f=0;fp){v.removeClass(h).addClass(k)}else{v.removeClass(k).addClass(h)}if(l==o){return}o=l;clearTimeout(s)}catch(m){}s=setTimeout(n,100)}function n(){try{v.removeClass(w);v.addClass((o<=360)?i:(o<=640)?u:(o<=768)?x:(o<=1024)?r:"pc")}catch(l){}}q(b).on("resize orientationchange",d).trigger("resize")})(b.jQuery)}})(document,window); diff --git a/motioneye/static/js/frame.js b/motioneye/static/js/frame.js new file mode 100644 index 0000000..276e100 --- /dev/null +++ b/motioneye/static/js/frame.js @@ -0,0 +1,150 @@ + +var refreshDisabled = false; + + + /* camera frame */ + +function setupCameraFrame() { + var cameraFrameDiv = $('div.camera-frame') + var cameraPlaceholder = cameraFrameDiv.find('div.camera-placeholder'); + var cameraProgress = cameraFrameDiv.find('div.camera-progress'); + var cameraImg = cameraFrameDiv.find('img.camera'); + var cameraId = cameraFrameDiv.attr('id').substring(6); + var progressImg = cameraFrameDiv.find('img.camera-progress'); + var body = $('body'); + + cameraFrameDiv[0].refreshDivider = 0; + cameraFrameDiv[0].streamingFramerate = parseInt(cameraFrameDiv.attr('streaming_framerate')) || 1; + cameraFrameDiv[0].streamingServerResize = cameraFrameDiv.attr('streaming_server_resize') == 'True'; + cameraFrameDiv[0].proto = cameraFrameDiv.attr('proto'); + cameraFrameDiv[0].url = cameraFrameDiv.attr('url'); + progressImg.attr('src', staticUrl + 'img/camera-progress.gif'); + + cameraProgress.addClass('visible'); + cameraPlaceholder.css('opacity', '0'); + + /* fade in */ + cameraFrameDiv.animate({'opacity': 1}, 100); + + /* error and load handlers */ + cameraImg.error(function () { + this.error = true; + this.loading = 0; + + cameraImg.addClass('error').removeClass('loading'); + cameraPlaceholder.css('opacity', 1); + cameraProgress.removeClass('visible'); + cameraFrameDiv.removeClass('motion-detected'); + }); + cameraImg.load(function () { + if (refreshDisabled) { + return; /* refresh temporarily disabled for updating */ + } + + this.error = false; + this.loading = 0; + + cameraImg.removeClass('error').removeClass('loading'); + cameraPlaceholder.css('opacity', 0); + cameraProgress.removeClass('visible'); + + /* there's no point in looking for a cookie update more often than once every second */ + var now = new Date().getTime(); + if ((!this.lastCookieTime || now - this.lastCookieTime > 1000) && (cameraFrameDiv[0].proto != 'mjpeg')) { + if (getCookie('motion_detected_' + cameraId) == 'true') { + cameraFrameDiv.addClass('motion-detected'); + } + else { + cameraFrameDiv.removeClass('motion-detected'); + } + + this.lastCookieTime = now; + } + + if (this.naturalWidth / this.naturalHeight > body.width() / body.height()) { + cameraImg.css('width', '100%'); + cameraImg.css('height', 'auto'); + } + else { + cameraImg.css('width', 'auto'); + cameraImg.css('height', '100%'); + } + }); + + cameraImg.addClass('loading'); +} + +function refreshCameraFrame() { + var $cameraFrame = $('div.camera-frame'); + var cameraFrame = $cameraFrame[0]; + var img = $cameraFrame.find('img.camera')[0]; + var cameraId = cameraFrame.id.substring(6); + + if (cameraFrame.proto == 'mjpeg') { + /* no manual refresh for simple mjpeg cameras */ + var url = cameraFrame.url.replace('127.0.0.1', window.location.host.split(':')[0]); + url += (url.indexOf('?') > 0 ? '&' : '?') + '_=' + new Date().getTime(); + img.src = url; + return; + } + + /* at a refresh interval of 50ms, the refresh rate is limited to 20 fps */ + var count = 1000 / (refreshInterval * cameraFrame.streamingFramerate); + if (count <= 2) { + /* skipping frames (showing the same frame twice) at this rate won't be visible, + * while the effective framerate will be as close as possible to the motion's one */ + count -= 1; + } + + if (img.error) { + /* in case of error, decrease the refresh rate to 1 fps */ + count = 1000 / refreshInterval; + } + + if (cameraFrame.refreshDivider < count) { + cameraFrame.refreshDivider++; + } + else { + (function () { + if (refreshDisabled) { + /* camera refreshing disabled, retry later */ + + return; + } + + if (img.loading) { + img.loading++; /* increases each time the camera would refresh but is still loading */ + + if (img.loading > 2 * 1000 / refreshInterval) { /* limits the retry at one every two seconds */ + img.loading = 0; + } + else { + return; /* wait for the previous frame to finish loading */ + } + } + + var timestamp = new Date().getTime(); + var uri = baseUri + 'picture/' + cameraId + '/current/?_=' + timestamp; + if (cameraFrame.serverSideResize) { + uri += '&width=' + img.width; + } + + uri = addAuthParams('GET', uri); + img.src = uri; + img.loading = 1; + + cameraFrame.refreshDivider = 0; + })(); + } + + setTimeout(refreshCameraFrame, refreshInterval); +} + + + /* startup function */ + +$(document).ready(function () { + setupCameraFrame(); + refreshCameraFrame(); +}); + diff --git a/motioneye/static/js/jquery.min.js b/motioneye/static/js/jquery.min.js new file mode 100644 index 0000000..ce1b6b6 --- /dev/null +++ b/motioneye/static/js/jquery.min.js @@ -0,0 +1,5 @@ +/*! jQuery v1.10.2 | (c) 2005, 2013 jQuery Foundation, Inc. | jquery.org/license +*/ +(function(e,t){var n,r,i=typeof t,o=e.location,a=e.document,s=a.documentElement,l=e.jQuery,u=e.$,c={},p=[],f="1.10.2",d=p.concat,h=p.push,g=p.slice,m=p.indexOf,y=c.toString,v=c.hasOwnProperty,b=f.trim,x=function(e,t){return new x.fn.init(e,t,r)},w=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,T=/\S+/g,C=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,N=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,k=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,E=/^[\],:{}\s]*$/,S=/(?:^|:|,)(?:\s*\[)+/g,A=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,j=/"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g,D=/^-ms-/,L=/-([\da-z])/gi,H=function(e,t){return t.toUpperCase()},q=function(e){(a.addEventListener||"load"===e.type||"complete"===a.readyState)&&(_(),x.ready())},_=function(){a.addEventListener?(a.removeEventListener("DOMContentLoaded",q,!1),e.removeEventListener("load",q,!1)):(a.detachEvent("onreadystatechange",q),e.detachEvent("onload",q))};x.fn=x.prototype={jquery:f,constructor:x,init:function(e,n,r){var i,o;if(!e)return this;if("string"==typeof e){if(i="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:N.exec(e),!i||!i[1]&&n)return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e);if(i[1]){if(n=n instanceof x?n[0]:n,x.merge(this,x.parseHTML(i[1],n&&n.nodeType?n.ownerDocument||n:a,!0)),k.test(i[1])&&x.isPlainObject(n))for(i in n)x.isFunction(this[i])?this[i](n[i]):this.attr(i,n[i]);return this}if(o=a.getElementById(i[2]),o&&o.parentNode){if(o.id!==i[2])return r.find(e);this.length=1,this[0]=o}return this.context=a,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):x.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),x.makeArray(e,this))},selector:"",length:0,toArray:function(){return g.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=x.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return x.each(this,e,t)},ready:function(e){return x.ready.promise().done(e),this},slice:function(){return this.pushStack(g.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(x.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:h,sort:[].sort,splice:[].splice},x.fn.init.prototype=x.fn,x.extend=x.fn.extend=function(){var e,n,r,i,o,a,s=arguments[0]||{},l=1,u=arguments.length,c=!1;for("boolean"==typeof s&&(c=s,s=arguments[1]||{},l=2),"object"==typeof s||x.isFunction(s)||(s={}),u===l&&(s=this,--l);u>l;l++)if(null!=(o=arguments[l]))for(i in o)e=s[i],r=o[i],s!==r&&(c&&r&&(x.isPlainObject(r)||(n=x.isArray(r)))?(n?(n=!1,a=e&&x.isArray(e)?e:[]):a=e&&x.isPlainObject(e)?e:{},s[i]=x.extend(c,a,r)):r!==t&&(s[i]=r));return s},x.extend({expando:"jQuery"+(f+Math.random()).replace(/\D/g,""),noConflict:function(t){return e.$===x&&(e.$=u),t&&e.jQuery===x&&(e.jQuery=l),x},isReady:!1,readyWait:1,holdReady:function(e){e?x.readyWait++:x.ready(!0)},ready:function(e){if(e===!0?!--x.readyWait:!x.isReady){if(!a.body)return setTimeout(x.ready);x.isReady=!0,e!==!0&&--x.readyWait>0||(n.resolveWith(a,[x]),x.fn.trigger&&x(a).trigger("ready").off("ready"))}},isFunction:function(e){return"function"===x.type(e)},isArray:Array.isArray||function(e){return"array"===x.type(e)},isWindow:function(e){return null!=e&&e==e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?c[y.call(e)]||"object":typeof e},isPlainObject:function(e){var n;if(!e||"object"!==x.type(e)||e.nodeType||x.isWindow(e))return!1;try{if(e.constructor&&!v.call(e,"constructor")&&!v.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(r){return!1}if(x.support.ownLast)for(n in e)return v.call(e,n);for(n in e);return n===t||v.call(e,n)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||a;var r=k.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=x.buildFragment([e],t,i),i&&x(i).remove(),x.merge([],r.childNodes))},parseJSON:function(n){return e.JSON&&e.JSON.parse?e.JSON.parse(n):null===n?n:"string"==typeof n&&(n=x.trim(n),n&&E.test(n.replace(A,"@").replace(j,"]").replace(S,"")))?Function("return "+n)():(x.error("Invalid JSON: "+n),t)},parseXML:function(n){var r,i;if(!n||"string"!=typeof n)return null;try{e.DOMParser?(i=new DOMParser,r=i.parseFromString(n,"text/xml")):(r=new ActiveXObject("Microsoft.XMLDOM"),r.async="false",r.loadXML(n))}catch(o){r=t}return r&&r.documentElement&&!r.getElementsByTagName("parsererror").length||x.error("Invalid XML: "+n),r},noop:function(){},globalEval:function(t){t&&x.trim(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(D,"ms-").replace(L,H)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,n){var r,i=0,o=e.length,a=M(e);if(n){if(a){for(;o>i;i++)if(r=t.apply(e[i],n),r===!1)break}else for(i in e)if(r=t.apply(e[i],n),r===!1)break}else if(a){for(;o>i;i++)if(r=t.call(e[i],i,e[i]),r===!1)break}else for(i in e)if(r=t.call(e[i],i,e[i]),r===!1)break;return e},trim:b&&!b.call("\ufeff\u00a0")?function(e){return null==e?"":b.call(e)}:function(e){return null==e?"":(e+"").replace(C,"")},makeArray:function(e,t){var n=t||[];return null!=e&&(M(Object(e))?x.merge(n,"string"==typeof e?[e]:e):h.call(n,e)),n},inArray:function(e,t,n){var r;if(t){if(m)return m.call(t,e,n);for(r=t.length,n=n?0>n?Math.max(0,r+n):n:0;r>n;n++)if(n in t&&t[n]===e)return n}return-1},merge:function(e,n){var r=n.length,i=e.length,o=0;if("number"==typeof r)for(;r>o;o++)e[i++]=n[o];else while(n[o]!==t)e[i++]=n[o++];return e.length=i,e},grep:function(e,t,n){var r,i=[],o=0,a=e.length;for(n=!!n;a>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,n){var r,i=0,o=e.length,a=M(e),s=[];if(a)for(;o>i;i++)r=t(e[i],i,n),null!=r&&(s[s.length]=r);else for(i in e)r=t(e[i],i,n),null!=r&&(s[s.length]=r);return d.apply([],s)},guid:1,proxy:function(e,n){var r,i,o;return"string"==typeof n&&(o=e[n],n=e,e=o),x.isFunction(e)?(r=g.call(arguments,2),i=function(){return e.apply(n||this,r.concat(g.call(arguments)))},i.guid=e.guid=e.guid||x.guid++,i):t},access:function(e,n,r,i,o,a,s){var l=0,u=e.length,c=null==r;if("object"===x.type(r)){o=!0;for(l in r)x.access(e,n,l,r[l],!0,a,s)}else if(i!==t&&(o=!0,x.isFunction(i)||(s=!0),c&&(s?(n.call(e,i),n=null):(c=n,n=function(e,t,n){return c.call(x(e),n)})),n))for(;u>l;l++)n(e[l],r,s?i:i.call(e[l],l,n(e[l],r)));return o?e:c?n.call(e):u?n(e[0],r):a},now:function(){return(new Date).getTime()},swap:function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=a[o];return i}}),x.ready.promise=function(t){if(!n)if(n=x.Deferred(),"complete"===a.readyState)setTimeout(x.ready);else if(a.addEventListener)a.addEventListener("DOMContentLoaded",q,!1),e.addEventListener("load",q,!1);else{a.attachEvent("onreadystatechange",q),e.attachEvent("onload",q);var r=!1;try{r=null==e.frameElement&&a.documentElement}catch(i){}r&&r.doScroll&&function o(){if(!x.isReady){try{r.doScroll("left")}catch(e){return setTimeout(o,50)}_(),x.ready()}}()}return n.promise(t)},x.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){c["[object "+t+"]"]=t.toLowerCase()});function M(e){var t=e.length,n=x.type(e);return x.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}r=x(a),function(e,t){var n,r,i,o,a,s,l,u,c,p,f,d,h,g,m,y,v,b="sizzle"+-new Date,w=e.document,T=0,C=0,N=st(),k=st(),E=st(),S=!1,A=function(e,t){return e===t?(S=!0,0):0},j=typeof t,D=1<<31,L={}.hasOwnProperty,H=[],q=H.pop,_=H.push,M=H.push,O=H.slice,F=H.indexOf||function(e){var t=0,n=this.length;for(;n>t;t++)if(this[t]===e)return t;return-1},B="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",P="[\\x20\\t\\r\\n\\f]",R="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",W=R.replace("w","w#"),$="\\["+P+"*("+R+")"+P+"*(?:([*^$|!~]?=)"+P+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+W+")|)|)"+P+"*\\]",I=":("+R+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+$.replace(3,8)+")*)|.*)\\)|)",z=RegExp("^"+P+"+|((?:^|[^\\\\])(?:\\\\.)*)"+P+"+$","g"),X=RegExp("^"+P+"*,"+P+"*"),U=RegExp("^"+P+"*([>+~]|"+P+")"+P+"*"),V=RegExp(P+"*[+~]"),Y=RegExp("="+P+"*([^\\]'\"]*)"+P+"*\\]","g"),J=RegExp(I),G=RegExp("^"+W+"$"),Q={ID:RegExp("^#("+R+")"),CLASS:RegExp("^\\.("+R+")"),TAG:RegExp("^("+R.replace("w","w*")+")"),ATTR:RegExp("^"+$),PSEUDO:RegExp("^"+I),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+P+"*(even|odd|(([+-]|)(\\d*)n|)"+P+"*(?:([+-]|)"+P+"*(\\d+)|))"+P+"*\\)|)","i"),bool:RegExp("^(?:"+B+")$","i"),needsContext:RegExp("^"+P+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+P+"*((?:-\\d)?\\d*)"+P+"*\\)|)(?=[^-]|$)","i")},K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,et=/^(?:input|select|textarea|button)$/i,tt=/^h\d$/i,nt=/'|\\/g,rt=RegExp("\\\\([\\da-f]{1,6}"+P+"?|("+P+")|.)","ig"),it=function(e,t,n){var r="0x"+t-65536;return r!==r||n?t:0>r?String.fromCharCode(r+65536):String.fromCharCode(55296|r>>10,56320|1023&r)};try{M.apply(H=O.call(w.childNodes),w.childNodes),H[w.childNodes.length].nodeType}catch(ot){M={apply:H.length?function(e,t){_.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function at(e,t,n,i){var o,a,s,l,u,c,d,m,y,x;if((t?t.ownerDocument||t:w)!==f&&p(t),t=t||f,n=n||[],!e||"string"!=typeof e)return n;if(1!==(l=t.nodeType)&&9!==l)return[];if(h&&!i){if(o=Z.exec(e))if(s=o[1]){if(9===l){if(a=t.getElementById(s),!a||!a.parentNode)return n;if(a.id===s)return n.push(a),n}else if(t.ownerDocument&&(a=t.ownerDocument.getElementById(s))&&v(t,a)&&a.id===s)return n.push(a),n}else{if(o[2])return M.apply(n,t.getElementsByTagName(e)),n;if((s=o[3])&&r.getElementsByClassName&&t.getElementsByClassName)return M.apply(n,t.getElementsByClassName(s)),n}if(r.qsa&&(!g||!g.test(e))){if(m=d=b,y=t,x=9===l&&e,1===l&&"object"!==t.nodeName.toLowerCase()){c=mt(e),(d=t.getAttribute("id"))?m=d.replace(nt,"\\$&"):t.setAttribute("id",m),m="[id='"+m+"'] ",u=c.length;while(u--)c[u]=m+yt(c[u]);y=V.test(e)&&t.parentNode||t,x=c.join(",")}if(x)try{return M.apply(n,y.querySelectorAll(x)),n}catch(T){}finally{d||t.removeAttribute("id")}}}return kt(e.replace(z,"$1"),t,n,i)}function st(){var e=[];function t(n,r){return e.push(n+=" ")>o.cacheLength&&delete t[e.shift()],t[n]=r}return t}function lt(e){return e[b]=!0,e}function ut(e){var t=f.createElement("div");try{return!!e(t)}catch(n){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function ct(e,t){var n=e.split("|"),r=e.length;while(r--)o.attrHandle[n[r]]=t}function pt(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&(~t.sourceIndex||D)-(~e.sourceIndex||D);if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function ft(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function dt(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function ht(e){return lt(function(t){return t=+t,lt(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}s=at.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},r=at.support={},p=at.setDocument=function(e){var n=e?e.ownerDocument||e:w,i=n.defaultView;return n!==f&&9===n.nodeType&&n.documentElement?(f=n,d=n.documentElement,h=!s(n),i&&i.attachEvent&&i!==i.top&&i.attachEvent("onbeforeunload",function(){p()}),r.attributes=ut(function(e){return e.className="i",!e.getAttribute("className")}),r.getElementsByTagName=ut(function(e){return e.appendChild(n.createComment("")),!e.getElementsByTagName("*").length}),r.getElementsByClassName=ut(function(e){return e.innerHTML="
",e.firstChild.className="i",2===e.getElementsByClassName("i").length}),r.getById=ut(function(e){return d.appendChild(e).id=b,!n.getElementsByName||!n.getElementsByName(b).length}),r.getById?(o.find.ID=function(e,t){if(typeof t.getElementById!==j&&h){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},o.filter.ID=function(e){var t=e.replace(rt,it);return function(e){return e.getAttribute("id")===t}}):(delete o.find.ID,o.filter.ID=function(e){var t=e.replace(rt,it);return function(e){var n=typeof e.getAttributeNode!==j&&e.getAttributeNode("id");return n&&n.value===t}}),o.find.TAG=r.getElementsByTagName?function(e,n){return typeof n.getElementsByTagName!==j?n.getElementsByTagName(e):t}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},o.find.CLASS=r.getElementsByClassName&&function(e,n){return typeof n.getElementsByClassName!==j&&h?n.getElementsByClassName(e):t},m=[],g=[],(r.qsa=K.test(n.querySelectorAll))&&(ut(function(e){e.innerHTML="",e.querySelectorAll("[selected]").length||g.push("\\["+P+"*(?:value|"+B+")"),e.querySelectorAll(":checked").length||g.push(":checked")}),ut(function(e){var t=n.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("t",""),e.querySelectorAll("[t^='']").length&&g.push("[*^$]="+P+"*(?:''|\"\")"),e.querySelectorAll(":enabled").length||g.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),g.push(",.*:")})),(r.matchesSelector=K.test(y=d.webkitMatchesSelector||d.mozMatchesSelector||d.oMatchesSelector||d.msMatchesSelector))&&ut(function(e){r.disconnectedMatch=y.call(e,"div"),y.call(e,"[s!='']:x"),m.push("!=",I)}),g=g.length&&RegExp(g.join("|")),m=m.length&&RegExp(m.join("|")),v=K.test(d.contains)||d.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},A=d.compareDocumentPosition?function(e,t){if(e===t)return S=!0,0;var i=t.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(t);return i?1&i||!r.sortDetached&&t.compareDocumentPosition(e)===i?e===n||v(w,e)?-1:t===n||v(w,t)?1:c?F.call(c,e)-F.call(c,t):0:4&i?-1:1:e.compareDocumentPosition?-1:1}:function(e,t){var r,i=0,o=e.parentNode,a=t.parentNode,s=[e],l=[t];if(e===t)return S=!0,0;if(!o||!a)return e===n?-1:t===n?1:o?-1:a?1:c?F.call(c,e)-F.call(c,t):0;if(o===a)return pt(e,t);r=e;while(r=r.parentNode)s.unshift(r);r=t;while(r=r.parentNode)l.unshift(r);while(s[i]===l[i])i++;return i?pt(s[i],l[i]):s[i]===w?-1:l[i]===w?1:0},n):f},at.matches=function(e,t){return at(e,null,null,t)},at.matchesSelector=function(e,t){if((e.ownerDocument||e)!==f&&p(e),t=t.replace(Y,"='$1']"),!(!r.matchesSelector||!h||m&&m.test(t)||g&&g.test(t)))try{var n=y.call(e,t);if(n||r.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(i){}return at(t,f,null,[e]).length>0},at.contains=function(e,t){return(e.ownerDocument||e)!==f&&p(e),v(e,t)},at.attr=function(e,n){(e.ownerDocument||e)!==f&&p(e);var i=o.attrHandle[n.toLowerCase()],a=i&&L.call(o.attrHandle,n.toLowerCase())?i(e,n,!h):t;return a===t?r.attributes||!h?e.getAttribute(n):(a=e.getAttributeNode(n))&&a.specified?a.value:null:a},at.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},at.uniqueSort=function(e){var t,n=[],i=0,o=0;if(S=!r.detectDuplicates,c=!r.sortStable&&e.slice(0),e.sort(A),S){while(t=e[o++])t===e[o]&&(i=n.push(o));while(i--)e.splice(n[i],1)}return e},a=at.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=a(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=a(t);return n},o=at.selectors={cacheLength:50,createPseudo:lt,match:Q,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(rt,it),e[3]=(e[4]||e[5]||"").replace(rt,it),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||at.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&at.error(e[0]),e},PSEUDO:function(e){var n,r=!e[5]&&e[2];return Q.CHILD.test(e[0])?null:(e[3]&&e[4]!==t?e[2]=e[4]:r&&J.test(r)&&(n=mt(r,!0))&&(n=r.indexOf(")",r.length-n)-r.length)&&(e[0]=e[0].slice(0,n),e[2]=r.slice(0,n)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(rt,it).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=N[e+" "];return t||(t=RegExp("(^|"+P+")"+e+"("+P+"|$)"))&&N(e,function(e){return t.test("string"==typeof e.className&&e.className||typeof e.getAttribute!==j&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=at.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.slice(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,l){var u,c,p,f,d,h,g=o!==a?"nextSibling":"previousSibling",m=t.parentNode,y=s&&t.nodeName.toLowerCase(),v=!l&&!s;if(m){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===y:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&v){c=m[b]||(m[b]={}),u=c[e]||[],d=u[0]===T&&u[1],f=u[0]===T&&u[2],p=d&&m.childNodes[d];while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if(1===p.nodeType&&++f&&p===t){c[e]=[T,d,f];break}}else if(v&&(u=(t[b]||(t[b]={}))[e])&&u[0]===T)f=u[1];else while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===y:1===p.nodeType)&&++f&&(v&&((p[b]||(p[b]={}))[e]=[T,f]),p===t))break;return f-=i,f===r||0===f%r&&f/r>=0}}},PSEUDO:function(e,t){var n,r=o.pseudos[e]||o.setFilters[e.toLowerCase()]||at.error("unsupported pseudo: "+e);return r[b]?r(t):r.length>1?(n=[e,e,"",t],o.setFilters.hasOwnProperty(e.toLowerCase())?lt(function(e,n){var i,o=r(e,t),a=o.length;while(a--)i=F.call(e,o[a]),e[i]=!(n[i]=o[a])}):function(e){return r(e,0,n)}):r}},pseudos:{not:lt(function(e){var t=[],n=[],r=l(e.replace(z,"$1"));return r[b]?lt(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:lt(function(e){return function(t){return at(e,t).length>0}}),contains:lt(function(e){return function(t){return(t.textContent||t.innerText||a(t)).indexOf(e)>-1}}),lang:lt(function(e){return G.test(e||"")||at.error("unsupported lang: "+e),e=e.replace(rt,it).toLowerCase(),function(t){var n;do if(n=h?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===d},focus:function(e){return e===f.activeElement&&(!f.hasFocus||f.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!o.pseudos.empty(e)},header:function(e){return tt.test(e.nodeName)},input:function(e){return et.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:ht(function(){return[0]}),last:ht(function(e,t){return[t-1]}),eq:ht(function(e,t,n){return[0>n?n+t:n]}),even:ht(function(e,t){var n=0;for(;t>n;n+=2)e.push(n);return e}),odd:ht(function(e,t){var n=1;for(;t>n;n+=2)e.push(n);return e}),lt:ht(function(e,t,n){var r=0>n?n+t:n;for(;--r>=0;)e.push(r);return e}),gt:ht(function(e,t,n){var r=0>n?n+t:n;for(;t>++r;)e.push(r);return e})}},o.pseudos.nth=o.pseudos.eq;for(n in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})o.pseudos[n]=ft(n);for(n in{submit:!0,reset:!0})o.pseudos[n]=dt(n);function gt(){}gt.prototype=o.filters=o.pseudos,o.setFilters=new gt;function mt(e,t){var n,r,i,a,s,l,u,c=k[e+" "];if(c)return t?0:c.slice(0);s=e,l=[],u=o.preFilter;while(s){(!n||(r=X.exec(s)))&&(r&&(s=s.slice(r[0].length)||s),l.push(i=[])),n=!1,(r=U.exec(s))&&(n=r.shift(),i.push({value:n,type:r[0].replace(z," ")}),s=s.slice(n.length));for(a in o.filter)!(r=Q[a].exec(s))||u[a]&&!(r=u[a](r))||(n=r.shift(),i.push({value:n,type:a,matches:r}),s=s.slice(n.length));if(!n)break}return t?s.length:s?at.error(e):k(e,l).slice(0)}function yt(e){var t=0,n=e.length,r="";for(;n>t;t++)r+=e[t].value;return r}function vt(e,t,n){var r=t.dir,o=n&&"parentNode"===r,a=C++;return t.first?function(t,n,i){while(t=t[r])if(1===t.nodeType||o)return e(t,n,i)}:function(t,n,s){var l,u,c,p=T+" "+a;if(s){while(t=t[r])if((1===t.nodeType||o)&&e(t,n,s))return!0}else while(t=t[r])if(1===t.nodeType||o)if(c=t[b]||(t[b]={}),(u=c[r])&&u[0]===p){if((l=u[1])===!0||l===i)return l===!0}else if(u=c[r]=[p],u[1]=e(t,n,s)||i,u[1]===!0)return!0}}function bt(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function xt(e,t,n,r,i){var o,a=[],s=0,l=e.length,u=null!=t;for(;l>s;s++)(o=e[s])&&(!n||n(o,r,i))&&(a.push(o),u&&t.push(s));return a}function wt(e,t,n,r,i,o){return r&&!r[b]&&(r=wt(r)),i&&!i[b]&&(i=wt(i,o)),lt(function(o,a,s,l){var u,c,p,f=[],d=[],h=a.length,g=o||Nt(t||"*",s.nodeType?[s]:s,[]),m=!e||!o&&t?g:xt(g,f,e,s,l),y=n?i||(o?e:h||r)?[]:a:m;if(n&&n(m,y,s,l),r){u=xt(y,d),r(u,[],s,l),c=u.length;while(c--)(p=u[c])&&(y[d[c]]=!(m[d[c]]=p))}if(o){if(i||e){if(i){u=[],c=y.length;while(c--)(p=y[c])&&u.push(m[c]=p);i(null,y=[],u,l)}c=y.length;while(c--)(p=y[c])&&(u=i?F.call(o,p):f[c])>-1&&(o[u]=!(a[u]=p))}}else y=xt(y===a?y.splice(h,y.length):y),i?i(null,a,y,l):M.apply(a,y)})}function Tt(e){var t,n,r,i=e.length,a=o.relative[e[0].type],s=a||o.relative[" "],l=a?1:0,c=vt(function(e){return e===t},s,!0),p=vt(function(e){return F.call(t,e)>-1},s,!0),f=[function(e,n,r){return!a&&(r||n!==u)||((t=n).nodeType?c(e,n,r):p(e,n,r))}];for(;i>l;l++)if(n=o.relative[e[l].type])f=[vt(bt(f),n)];else{if(n=o.filter[e[l].type].apply(null,e[l].matches),n[b]){for(r=++l;i>r;r++)if(o.relative[e[r].type])break;return wt(l>1&&bt(f),l>1&&yt(e.slice(0,l-1).concat({value:" "===e[l-2].type?"*":""})).replace(z,"$1"),n,r>l&&Tt(e.slice(l,r)),i>r&&Tt(e=e.slice(r)),i>r&&yt(e))}f.push(n)}return bt(f)}function Ct(e,t){var n=0,r=t.length>0,a=e.length>0,s=function(s,l,c,p,d){var h,g,m,y=[],v=0,b="0",x=s&&[],w=null!=d,C=u,N=s||a&&o.find.TAG("*",d&&l.parentNode||l),k=T+=null==C?1:Math.random()||.1;for(w&&(u=l!==f&&l,i=n);null!=(h=N[b]);b++){if(a&&h){g=0;while(m=e[g++])if(m(h,l,c)){p.push(h);break}w&&(T=k,i=++n)}r&&((h=!m&&h)&&v--,s&&x.push(h))}if(v+=b,r&&b!==v){g=0;while(m=t[g++])m(x,y,l,c);if(s){if(v>0)while(b--)x[b]||y[b]||(y[b]=q.call(p));y=xt(y)}M.apply(p,y),w&&!s&&y.length>0&&v+t.length>1&&at.uniqueSort(p)}return w&&(T=k,u=C),x};return r?lt(s):s}l=at.compile=function(e,t){var n,r=[],i=[],o=E[e+" "];if(!o){t||(t=mt(e)),n=t.length;while(n--)o=Tt(t[n]),o[b]?r.push(o):i.push(o);o=E(e,Ct(i,r))}return o};function Nt(e,t,n){var r=0,i=t.length;for(;i>r;r++)at(e,t[r],n);return n}function kt(e,t,n,i){var a,s,u,c,p,f=mt(e);if(!i&&1===f.length){if(s=f[0]=f[0].slice(0),s.length>2&&"ID"===(u=s[0]).type&&r.getById&&9===t.nodeType&&h&&o.relative[s[1].type]){if(t=(o.find.ID(u.matches[0].replace(rt,it),t)||[])[0],!t)return n;e=e.slice(s.shift().value.length)}a=Q.needsContext.test(e)?0:s.length;while(a--){if(u=s[a],o.relative[c=u.type])break;if((p=o.find[c])&&(i=p(u.matches[0].replace(rt,it),V.test(s[0].type)&&t.parentNode||t))){if(s.splice(a,1),e=i.length&&yt(s),!e)return M.apply(n,i),n;break}}}return l(e,f)(i,t,!h,n,V.test(e)),n}r.sortStable=b.split("").sort(A).join("")===b,r.detectDuplicates=S,p(),r.sortDetached=ut(function(e){return 1&e.compareDocumentPosition(f.createElement("div"))}),ut(function(e){return e.innerHTML="","#"===e.firstChild.getAttribute("href")})||ct("type|href|height|width",function(e,n,r){return r?t:e.getAttribute(n,"type"===n.toLowerCase()?1:2)}),r.attributes&&ut(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||ct("value",function(e,n,r){return r||"input"!==e.nodeName.toLowerCase()?t:e.defaultValue}),ut(function(e){return null==e.getAttribute("disabled")})||ct(B,function(e,n,r){var i;return r?t:(i=e.getAttributeNode(n))&&i.specified?i.value:e[n]===!0?n.toLowerCase():null}),x.find=at,x.expr=at.selectors,x.expr[":"]=x.expr.pseudos,x.unique=at.uniqueSort,x.text=at.getText,x.isXMLDoc=at.isXML,x.contains=at.contains}(e);var O={};function F(e){var t=O[e]={};return x.each(e.match(T)||[],function(e,n){t[n]=!0}),t}x.Callbacks=function(e){e="string"==typeof e?O[e]||F(e):x.extend({},e);var n,r,i,o,a,s,l=[],u=!e.once&&[],c=function(t){for(r=e.memory&&t,i=!0,a=s||0,s=0,o=l.length,n=!0;l&&o>a;a++)if(l[a].apply(t[0],t[1])===!1&&e.stopOnFalse){r=!1;break}n=!1,l&&(u?u.length&&c(u.shift()):r?l=[]:p.disable())},p={add:function(){if(l){var t=l.length;(function i(t){x.each(t,function(t,n){var r=x.type(n);"function"===r?e.unique&&p.has(n)||l.push(n):n&&n.length&&"string"!==r&&i(n)})})(arguments),n?o=l.length:r&&(s=t,c(r))}return this},remove:function(){return l&&x.each(arguments,function(e,t){var r;while((r=x.inArray(t,l,r))>-1)l.splice(r,1),n&&(o>=r&&o--,a>=r&&a--)}),this},has:function(e){return e?x.inArray(e,l)>-1:!(!l||!l.length)},empty:function(){return l=[],o=0,this},disable:function(){return l=u=r=t,this},disabled:function(){return!l},lock:function(){return u=t,r||p.disable(),this},locked:function(){return!u},fireWith:function(e,t){return!l||i&&!u||(t=t||[],t=[e,t.slice?t.slice():t],n?u.push(t):c(t)),this},fire:function(){return p.fireWith(this,arguments),this},fired:function(){return!!i}};return p},x.extend({Deferred:function(e){var t=[["resolve","done",x.Callbacks("once memory"),"resolved"],["reject","fail",x.Callbacks("once memory"),"rejected"],["notify","progress",x.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return x.Deferred(function(n){x.each(t,function(t,o){var a=o[0],s=x.isFunction(e[t])&&e[t];i[o[1]](function(){var e=s&&s.apply(this,arguments);e&&x.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[a+"With"](this===r?n.promise():this,s?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?x.extend(e,r):r}},i={};return r.pipe=r.then,x.each(t,function(e,o){var a=o[2],s=o[3];r[o[1]]=a.add,s&&a.add(function(){n=s},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=a.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t=0,n=g.call(arguments),r=n.length,i=1!==r||e&&x.isFunction(e.promise)?r:0,o=1===i?e:x.Deferred(),a=function(e,t,n){return function(r){t[e]=this,n[e]=arguments.length>1?g.call(arguments):r,n===s?o.notifyWith(t,n):--i||o.resolveWith(t,n)}},s,l,u;if(r>1)for(s=Array(r),l=Array(r),u=Array(r);r>t;t++)n[t]&&x.isFunction(n[t].promise)?n[t].promise().done(a(t,u,n)).fail(o.reject).progress(a(t,l,s)):--i;return i||o.resolveWith(u,n),o.promise()}}),x.support=function(t){var n,r,o,s,l,u,c,p,f,d=a.createElement("div");if(d.setAttribute("className","t"),d.innerHTML="
a",n=d.getElementsByTagName("*")||[],r=d.getElementsByTagName("a")[0],!r||!r.style||!n.length)return t;s=a.createElement("select"),u=s.appendChild(a.createElement("option")),o=d.getElementsByTagName("input")[0],r.style.cssText="top:1px;float:left;opacity:.5",t.getSetAttribute="t"!==d.className,t.leadingWhitespace=3===d.firstChild.nodeType,t.tbody=!d.getElementsByTagName("tbody").length,t.htmlSerialize=!!d.getElementsByTagName("link").length,t.style=/top/.test(r.getAttribute("style")),t.hrefNormalized="/a"===r.getAttribute("href"),t.opacity=/^0.5/.test(r.style.opacity),t.cssFloat=!!r.style.cssFloat,t.checkOn=!!o.value,t.optSelected=u.selected,t.enctype=!!a.createElement("form").enctype,t.html5Clone="<:nav>"!==a.createElement("nav").cloneNode(!0).outerHTML,t.inlineBlockNeedsLayout=!1,t.shrinkWrapBlocks=!1,t.pixelPosition=!1,t.deleteExpando=!0,t.noCloneEvent=!0,t.reliableMarginRight=!0,t.boxSizingReliable=!0,o.checked=!0,t.noCloneChecked=o.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!u.disabled;try{delete d.test}catch(h){t.deleteExpando=!1}o=a.createElement("input"),o.setAttribute("value",""),t.input=""===o.getAttribute("value"),o.value="t",o.setAttribute("type","radio"),t.radioValue="t"===o.value,o.setAttribute("checked","t"),o.setAttribute("name","t"),l=a.createDocumentFragment(),l.appendChild(o),t.appendChecked=o.checked,t.checkClone=l.cloneNode(!0).cloneNode(!0).lastChild.checked,d.attachEvent&&(d.attachEvent("onclick",function(){t.noCloneEvent=!1}),d.cloneNode(!0).click());for(f in{submit:!0,change:!0,focusin:!0})d.setAttribute(c="on"+f,"t"),t[f+"Bubbles"]=c in e||d.attributes[c].expando===!1;d.style.backgroundClip="content-box",d.cloneNode(!0).style.backgroundClip="",t.clearCloneStyle="content-box"===d.style.backgroundClip;for(f in x(t))break;return t.ownLast="0"!==f,x(function(){var n,r,o,s="padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;",l=a.getElementsByTagName("body")[0];l&&(n=a.createElement("div"),n.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",l.appendChild(n).appendChild(d),d.innerHTML="
t
",o=d.getElementsByTagName("td"),o[0].style.cssText="padding:0;margin:0;border:0;display:none",p=0===o[0].offsetHeight,o[0].style.display="",o[1].style.display="none",t.reliableHiddenOffsets=p&&0===o[0].offsetHeight,d.innerHTML="",d.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",x.swap(l,null!=l.style.zoom?{zoom:1}:{},function(){t.boxSizing=4===d.offsetWidth}),e.getComputedStyle&&(t.pixelPosition="1%"!==(e.getComputedStyle(d,null)||{}).top,t.boxSizingReliable="4px"===(e.getComputedStyle(d,null)||{width:"4px"}).width,r=d.appendChild(a.createElement("div")),r.style.cssText=d.style.cssText=s,r.style.marginRight=r.style.width="0",d.style.width="1px",t.reliableMarginRight=!parseFloat((e.getComputedStyle(r,null)||{}).marginRight)),typeof d.style.zoom!==i&&(d.innerHTML="",d.style.cssText=s+"width:1px;padding:1px;display:inline;zoom:1",t.inlineBlockNeedsLayout=3===d.offsetWidth,d.style.display="block",d.innerHTML="
",d.firstChild.style.width="5px",t.shrinkWrapBlocks=3!==d.offsetWidth,t.inlineBlockNeedsLayout&&(l.style.zoom=1)),l.removeChild(n),n=d=o=r=null)}),n=s=l=u=r=o=null,t +}({});var B=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,P=/([A-Z])/g;function R(e,n,r,i){if(x.acceptData(e)){var o,a,s=x.expando,l=e.nodeType,u=l?x.cache:e,c=l?e[s]:e[s]&&s;if(c&&u[c]&&(i||u[c].data)||r!==t||"string"!=typeof n)return c||(c=l?e[s]=p.pop()||x.guid++:s),u[c]||(u[c]=l?{}:{toJSON:x.noop}),("object"==typeof n||"function"==typeof n)&&(i?u[c]=x.extend(u[c],n):u[c].data=x.extend(u[c].data,n)),a=u[c],i||(a.data||(a.data={}),a=a.data),r!==t&&(a[x.camelCase(n)]=r),"string"==typeof n?(o=a[n],null==o&&(o=a[x.camelCase(n)])):o=a,o}}function W(e,t,n){if(x.acceptData(e)){var r,i,o=e.nodeType,a=o?x.cache:e,s=o?e[x.expando]:x.expando;if(a[s]){if(t&&(r=n?a[s]:a[s].data)){x.isArray(t)?t=t.concat(x.map(t,x.camelCase)):t in r?t=[t]:(t=x.camelCase(t),t=t in r?[t]:t.split(" ")),i=t.length;while(i--)delete r[t[i]];if(n?!I(r):!x.isEmptyObject(r))return}(n||(delete a[s].data,I(a[s])))&&(o?x.cleanData([e],!0):x.support.deleteExpando||a!=a.window?delete a[s]:a[s]=null)}}}x.extend({cache:{},noData:{applet:!0,embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(e){return e=e.nodeType?x.cache[e[x.expando]]:e[x.expando],!!e&&!I(e)},data:function(e,t,n){return R(e,t,n)},removeData:function(e,t){return W(e,t)},_data:function(e,t,n){return R(e,t,n,!0)},_removeData:function(e,t){return W(e,t,!0)},acceptData:function(e){if(e.nodeType&&1!==e.nodeType&&9!==e.nodeType)return!1;var t=e.nodeName&&x.noData[e.nodeName.toLowerCase()];return!t||t!==!0&&e.getAttribute("classid")===t}}),x.fn.extend({data:function(e,n){var r,i,o=null,a=0,s=this[0];if(e===t){if(this.length&&(o=x.data(s),1===s.nodeType&&!x._data(s,"parsedAttrs"))){for(r=s.attributes;r.length>a;a++)i=r[a].name,0===i.indexOf("data-")&&(i=x.camelCase(i.slice(5)),$(s,i,o[i]));x._data(s,"parsedAttrs",!0)}return o}return"object"==typeof e?this.each(function(){x.data(this,e)}):arguments.length>1?this.each(function(){x.data(this,e,n)}):s?$(s,e,x.data(s,e)):null},removeData:function(e){return this.each(function(){x.removeData(this,e)})}});function $(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(P,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:B.test(r)?x.parseJSON(r):r}catch(o){}x.data(e,n,r)}else r=t}return r}function I(e){var t;for(t in e)if(("data"!==t||!x.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}x.extend({queue:function(e,n,r){var i;return e?(n=(n||"fx")+"queue",i=x._data(e,n),r&&(!i||x.isArray(r)?i=x._data(e,n,x.makeArray(r)):i.push(r)),i||[]):t},dequeue:function(e,t){t=t||"fx";var n=x.queue(e,t),r=n.length,i=n.shift(),o=x._queueHooks(e,t),a=function(){x.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return x._data(e,n)||x._data(e,n,{empty:x.Callbacks("once memory").add(function(){x._removeData(e,t+"queue"),x._removeData(e,n)})})}}),x.fn.extend({queue:function(e,n){var r=2;return"string"!=typeof e&&(n=e,e="fx",r--),r>arguments.length?x.queue(this[0],e):n===t?this:this.each(function(){var t=x.queue(this,e,n);x._queueHooks(this,e),"fx"===e&&"inprogress"!==t[0]&&x.dequeue(this,e)})},dequeue:function(e){return this.each(function(){x.dequeue(this,e)})},delay:function(e,t){return e=x.fx?x.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,n){var r,i=1,o=x.Deferred(),a=this,s=this.length,l=function(){--i||o.resolveWith(a,[a])};"string"!=typeof e&&(n=e,e=t),e=e||"fx";while(s--)r=x._data(a[s],e+"queueHooks"),r&&r.empty&&(i++,r.empty.add(l));return l(),o.promise(n)}});var z,X,U=/[\t\r\n\f]/g,V=/\r/g,Y=/^(?:input|select|textarea|button|object)$/i,J=/^(?:a|area)$/i,G=/^(?:checked|selected)$/i,Q=x.support.getSetAttribute,K=x.support.input;x.fn.extend({attr:function(e,t){return x.access(this,x.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){x.removeAttr(this,e)})},prop:function(e,t){return x.access(this,x.prop,e,t,arguments.length>1)},removeProp:function(e){return e=x.propFix[e]||e,this.each(function(){try{this[e]=t,delete this[e]}catch(n){}})},addClass:function(e){var t,n,r,i,o,a=0,s=this.length,l="string"==typeof e&&e;if(x.isFunction(e))return this.each(function(t){x(this).addClass(e.call(this,t,this.className))});if(l)for(t=(e||"").match(T)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(U," "):" ")){o=0;while(i=t[o++])0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=x.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,a=0,s=this.length,l=0===arguments.length||"string"==typeof e&&e;if(x.isFunction(e))return this.each(function(t){x(this).removeClass(e.call(this,t,this.className))});if(l)for(t=(e||"").match(T)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(U," "):"")){o=0;while(i=t[o++])while(r.indexOf(" "+i+" ")>=0)r=r.replace(" "+i+" "," ");n.className=e?x.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e;return"boolean"==typeof t&&"string"===n?t?this.addClass(e):this.removeClass(e):x.isFunction(e)?this.each(function(n){x(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n){var t,r=0,o=x(this),a=e.match(T)||[];while(t=a[r++])o.hasClass(t)?o.removeClass(t):o.addClass(t)}else(n===i||"boolean"===n)&&(this.className&&x._data(this,"__className__",this.className),this.className=this.className||e===!1?"":x._data(this,"__className__")||"")})},hasClass:function(e){var t=" "+e+" ",n=0,r=this.length;for(;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(U," ").indexOf(t)>=0)return!0;return!1},val:function(e){var n,r,i,o=this[0];{if(arguments.length)return i=x.isFunction(e),this.each(function(n){var o;1===this.nodeType&&(o=i?e.call(this,n,x(this).val()):e,null==o?o="":"number"==typeof o?o+="":x.isArray(o)&&(o=x.map(o,function(e){return null==e?"":e+""})),r=x.valHooks[this.type]||x.valHooks[this.nodeName.toLowerCase()],r&&"set"in r&&r.set(this,o,"value")!==t||(this.value=o))});if(o)return r=x.valHooks[o.type]||x.valHooks[o.nodeName.toLowerCase()],r&&"get"in r&&(n=r.get(o,"value"))!==t?n:(n=o.value,"string"==typeof n?n.replace(V,""):null==n?"":n)}}}),x.extend({valHooks:{option:{get:function(e){var t=x.find.attr(e,"value");return null!=t?t:e.text}},select:{get:function(e){var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,a=o?null:[],s=o?i+1:r.length,l=0>i?s:o?i:0;for(;s>l;l++)if(n=r[l],!(!n.selected&&l!==i||(x.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&x.nodeName(n.parentNode,"optgroup"))){if(t=x(n).val(),o)return t;a.push(t)}return a},set:function(e,t){var n,r,i=e.options,o=x.makeArray(t),a=i.length;while(a--)r=i[a],(r.selected=x.inArray(x(r).val(),o)>=0)&&(n=!0);return n||(e.selectedIndex=-1),o}}},attr:function(e,n,r){var o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return typeof e.getAttribute===i?x.prop(e,n,r):(1===s&&x.isXMLDoc(e)||(n=n.toLowerCase(),o=x.attrHooks[n]||(x.expr.match.bool.test(n)?X:z)),r===t?o&&"get"in o&&null!==(a=o.get(e,n))?a:(a=x.find.attr(e,n),null==a?t:a):null!==r?o&&"set"in o&&(a=o.set(e,r,n))!==t?a:(e.setAttribute(n,r+""),r):(x.removeAttr(e,n),t))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(T);if(o&&1===e.nodeType)while(n=o[i++])r=x.propFix[n]||n,x.expr.match.bool.test(n)?K&&Q||!G.test(n)?e[r]=!1:e[x.camelCase("default-"+n)]=e[r]=!1:x.attr(e,n,""),e.removeAttribute(Q?n:r)},attrHooks:{type:{set:function(e,t){if(!x.support.radioValue&&"radio"===t&&x.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{"for":"htmlFor","class":"className"},prop:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return a=1!==s||!x.isXMLDoc(e),a&&(n=x.propFix[n]||n,o=x.propHooks[n]),r!==t?o&&"set"in o&&(i=o.set(e,r,n))!==t?i:e[n]=r:o&&"get"in o&&null!==(i=o.get(e,n))?i:e[n]},propHooks:{tabIndex:{get:function(e){var t=x.find.attr(e,"tabindex");return t?parseInt(t,10):Y.test(e.nodeName)||J.test(e.nodeName)&&e.href?0:-1}}}}),X={set:function(e,t,n){return t===!1?x.removeAttr(e,n):K&&Q||!G.test(n)?e.setAttribute(!Q&&x.propFix[n]||n,n):e[x.camelCase("default-"+n)]=e[n]=!0,n}},x.each(x.expr.match.bool.source.match(/\w+/g),function(e,n){var r=x.expr.attrHandle[n]||x.find.attr;x.expr.attrHandle[n]=K&&Q||!G.test(n)?function(e,n,i){var o=x.expr.attrHandle[n],a=i?t:(x.expr.attrHandle[n]=t)!=r(e,n,i)?n.toLowerCase():null;return x.expr.attrHandle[n]=o,a}:function(e,n,r){return r?t:e[x.camelCase("default-"+n)]?n.toLowerCase():null}}),K&&Q||(x.attrHooks.value={set:function(e,n,r){return x.nodeName(e,"input")?(e.defaultValue=n,t):z&&z.set(e,n,r)}}),Q||(z={set:function(e,n,r){var i=e.getAttributeNode(r);return i||e.setAttributeNode(i=e.ownerDocument.createAttribute(r)),i.value=n+="","value"===r||n===e.getAttribute(r)?n:t}},x.expr.attrHandle.id=x.expr.attrHandle.name=x.expr.attrHandle.coords=function(e,n,r){var i;return r?t:(i=e.getAttributeNode(n))&&""!==i.value?i.value:null},x.valHooks.button={get:function(e,n){var r=e.getAttributeNode(n);return r&&r.specified?r.value:t},set:z.set},x.attrHooks.contenteditable={set:function(e,t,n){z.set(e,""===t?!1:t,n)}},x.each(["width","height"],function(e,n){x.attrHooks[n]={set:function(e,r){return""===r?(e.setAttribute(n,"auto"),r):t}}})),x.support.hrefNormalized||x.each(["href","src"],function(e,t){x.propHooks[t]={get:function(e){return e.getAttribute(t,4)}}}),x.support.style||(x.attrHooks.style={get:function(e){return e.style.cssText||t},set:function(e,t){return e.style.cssText=t+""}}),x.support.optSelected||(x.propHooks.selected={get:function(e){var t=e.parentNode;return t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex),null}}),x.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){x.propFix[this.toLowerCase()]=this}),x.support.enctype||(x.propFix.enctype="encoding"),x.each(["radio","checkbox"],function(){x.valHooks[this]={set:function(e,n){return x.isArray(n)?e.checked=x.inArray(x(e).val(),n)>=0:t}},x.support.checkOn||(x.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})});var Z=/^(?:input|select|textarea)$/i,et=/^key/,tt=/^(?:mouse|contextmenu)|click/,nt=/^(?:focusinfocus|focusoutblur)$/,rt=/^([^.]*)(?:\.(.+)|)$/;function it(){return!0}function ot(){return!1}function at(){try{return a.activeElement}catch(e){}}x.event={global:{},add:function(e,n,r,o,a){var s,l,u,c,p,f,d,h,g,m,y,v=x._data(e);if(v){r.handler&&(c=r,r=c.handler,a=c.selector),r.guid||(r.guid=x.guid++),(l=v.events)||(l=v.events={}),(f=v.handle)||(f=v.handle=function(e){return typeof x===i||e&&x.event.triggered===e.type?t:x.event.dispatch.apply(f.elem,arguments)},f.elem=e),n=(n||"").match(T)||[""],u=n.length;while(u--)s=rt.exec(n[u])||[],g=y=s[1],m=(s[2]||"").split(".").sort(),g&&(p=x.event.special[g]||{},g=(a?p.delegateType:p.bindType)||g,p=x.event.special[g]||{},d=x.extend({type:g,origType:y,data:o,handler:r,guid:r.guid,selector:a,needsContext:a&&x.expr.match.needsContext.test(a),namespace:m.join(".")},c),(h=l[g])||(h=l[g]=[],h.delegateCount=0,p.setup&&p.setup.call(e,o,m,f)!==!1||(e.addEventListener?e.addEventListener(g,f,!1):e.attachEvent&&e.attachEvent("on"+g,f))),p.add&&(p.add.call(e,d),d.handler.guid||(d.handler.guid=r.guid)),a?h.splice(h.delegateCount++,0,d):h.push(d),x.event.global[g]=!0);e=null}},remove:function(e,t,n,r,i){var o,a,s,l,u,c,p,f,d,h,g,m=x.hasData(e)&&x._data(e);if(m&&(c=m.events)){t=(t||"").match(T)||[""],u=t.length;while(u--)if(s=rt.exec(t[u])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){p=x.event.special[d]||{},d=(r?p.delegateType:p.bindType)||d,f=c[d]||[],s=s[2]&&RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),l=o=f.length;while(o--)a=f[o],!i&&g!==a.origType||n&&n.guid!==a.guid||s&&!s.test(a.namespace)||r&&r!==a.selector&&("**"!==r||!a.selector)||(f.splice(o,1),a.selector&&f.delegateCount--,p.remove&&p.remove.call(e,a));l&&!f.length&&(p.teardown&&p.teardown.call(e,h,m.handle)!==!1||x.removeEvent(e,d,m.handle),delete c[d])}else for(d in c)x.event.remove(e,d+t[u],n,r,!0);x.isEmptyObject(c)&&(delete m.handle,x._removeData(e,"events"))}},trigger:function(n,r,i,o){var s,l,u,c,p,f,d,h=[i||a],g=v.call(n,"type")?n.type:n,m=v.call(n,"namespace")?n.namespace.split("."):[];if(u=f=i=i||a,3!==i.nodeType&&8!==i.nodeType&&!nt.test(g+x.event.triggered)&&(g.indexOf(".")>=0&&(m=g.split("."),g=m.shift(),m.sort()),l=0>g.indexOf(":")&&"on"+g,n=n[x.expando]?n:new x.Event(g,"object"==typeof n&&n),n.isTrigger=o?2:3,n.namespace=m.join("."),n.namespace_re=n.namespace?RegExp("(^|\\.)"+m.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,n.result=t,n.target||(n.target=i),r=null==r?[n]:x.makeArray(r,[n]),p=x.event.special[g]||{},o||!p.trigger||p.trigger.apply(i,r)!==!1)){if(!o&&!p.noBubble&&!x.isWindow(i)){for(c=p.delegateType||g,nt.test(c+g)||(u=u.parentNode);u;u=u.parentNode)h.push(u),f=u;f===(i.ownerDocument||a)&&h.push(f.defaultView||f.parentWindow||e)}d=0;while((u=h[d++])&&!n.isPropagationStopped())n.type=d>1?c:p.bindType||g,s=(x._data(u,"events")||{})[n.type]&&x._data(u,"handle"),s&&s.apply(u,r),s=l&&u[l],s&&x.acceptData(u)&&s.apply&&s.apply(u,r)===!1&&n.preventDefault();if(n.type=g,!o&&!n.isDefaultPrevented()&&(!p._default||p._default.apply(h.pop(),r)===!1)&&x.acceptData(i)&&l&&i[g]&&!x.isWindow(i)){f=i[l],f&&(i[l]=null),x.event.triggered=g;try{i[g]()}catch(y){}x.event.triggered=t,f&&(i[l]=f)}return n.result}},dispatch:function(e){e=x.event.fix(e);var n,r,i,o,a,s=[],l=g.call(arguments),u=(x._data(this,"events")||{})[e.type]||[],c=x.event.special[e.type]||{};if(l[0]=e,e.delegateTarget=this,!c.preDispatch||c.preDispatch.call(this,e)!==!1){s=x.event.handlers.call(this,e,u),n=0;while((o=s[n++])&&!e.isPropagationStopped()){e.currentTarget=o.elem,a=0;while((i=o.handlers[a++])&&!e.isImmediatePropagationStopped())(!e.namespace_re||e.namespace_re.test(i.namespace))&&(e.handleObj=i,e.data=i.data,r=((x.event.special[i.origType]||{}).handle||i.handler).apply(o.elem,l),r!==t&&(e.result=r)===!1&&(e.preventDefault(),e.stopPropagation()))}return c.postDispatch&&c.postDispatch.call(this,e),e.result}},handlers:function(e,n){var r,i,o,a,s=[],l=n.delegateCount,u=e.target;if(l&&u.nodeType&&(!e.button||"click"!==e.type))for(;u!=this;u=u.parentNode||this)if(1===u.nodeType&&(u.disabled!==!0||"click"!==e.type)){for(o=[],a=0;l>a;a++)i=n[a],r=i.selector+" ",o[r]===t&&(o[r]=i.needsContext?x(r,this).index(u)>=0:x.find(r,this,null,[u]).length),o[r]&&o.push(i);o.length&&s.push({elem:u,handlers:o})}return n.length>l&&s.push({elem:this,handlers:n.slice(l)}),s},fix:function(e){if(e[x.expando])return e;var t,n,r,i=e.type,o=e,s=this.fixHooks[i];s||(this.fixHooks[i]=s=tt.test(i)?this.mouseHooks:et.test(i)?this.keyHooks:{}),r=s.props?this.props.concat(s.props):this.props,e=new x.Event(o),t=r.length;while(t--)n=r[t],e[n]=o[n];return e.target||(e.target=o.srcElement||a),3===e.target.nodeType&&(e.target=e.target.parentNode),e.metaKey=!!e.metaKey,s.filter?s.filter(e,o):e},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,n){var r,i,o,s=n.button,l=n.fromElement;return null==e.pageX&&null!=n.clientX&&(i=e.target.ownerDocument||a,o=i.documentElement,r=i.body,e.pageX=n.clientX+(o&&o.scrollLeft||r&&r.scrollLeft||0)-(o&&o.clientLeft||r&&r.clientLeft||0),e.pageY=n.clientY+(o&&o.scrollTop||r&&r.scrollTop||0)-(o&&o.clientTop||r&&r.clientTop||0)),!e.relatedTarget&&l&&(e.relatedTarget=l===e.target?n.toElement:l),e.which||s===t||(e.which=1&s?1:2&s?3:4&s?2:0),e}},special:{load:{noBubble:!0},focus:{trigger:function(){if(this!==at()&&this.focus)try{return this.focus(),!1}catch(e){}},delegateType:"focusin"},blur:{trigger:function(){return this===at()&&this.blur?(this.blur(),!1):t},delegateType:"focusout"},click:{trigger:function(){return x.nodeName(this,"input")&&"checkbox"===this.type&&this.click?(this.click(),!1):t},_default:function(e){return x.nodeName(e.target,"a")}},beforeunload:{postDispatch:function(e){e.result!==t&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=x.extend(new x.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?x.event.trigger(i,null,t):x.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},x.removeEvent=a.removeEventListener?function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)}:function(e,t,n){var r="on"+t;e.detachEvent&&(typeof e[r]===i&&(e[r]=null),e.detachEvent(r,n))},x.Event=function(e,n){return this instanceof x.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.returnValue===!1||e.getPreventDefault&&e.getPreventDefault()?it:ot):this.type=e,n&&x.extend(this,n),this.timeStamp=e&&e.timeStamp||x.now(),this[x.expando]=!0,t):new x.Event(e,n)},x.Event.prototype={isDefaultPrevented:ot,isPropagationStopped:ot,isImmediatePropagationStopped:ot,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=it,e&&(e.preventDefault?e.preventDefault():e.returnValue=!1)},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=it,e&&(e.stopPropagation&&e.stopPropagation(),e.cancelBubble=!0)},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=it,this.stopPropagation()}},x.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){x.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj;return(!i||i!==r&&!x.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),x.support.submitBubbles||(x.event.special.submit={setup:function(){return x.nodeName(this,"form")?!1:(x.event.add(this,"click._submit keypress._submit",function(e){var n=e.target,r=x.nodeName(n,"input")||x.nodeName(n,"button")?n.form:t;r&&!x._data(r,"submitBubbles")&&(x.event.add(r,"submit._submit",function(e){e._submit_bubble=!0}),x._data(r,"submitBubbles",!0))}),t)},postDispatch:function(e){e._submit_bubble&&(delete e._submit_bubble,this.parentNode&&!e.isTrigger&&x.event.simulate("submit",this.parentNode,e,!0))},teardown:function(){return x.nodeName(this,"form")?!1:(x.event.remove(this,"._submit"),t)}}),x.support.changeBubbles||(x.event.special.change={setup:function(){return Z.test(this.nodeName)?(("checkbox"===this.type||"radio"===this.type)&&(x.event.add(this,"propertychange._change",function(e){"checked"===e.originalEvent.propertyName&&(this._just_changed=!0)}),x.event.add(this,"click._change",function(e){this._just_changed&&!e.isTrigger&&(this._just_changed=!1),x.event.simulate("change",this,e,!0)})),!1):(x.event.add(this,"beforeactivate._change",function(e){var t=e.target;Z.test(t.nodeName)&&!x._data(t,"changeBubbles")&&(x.event.add(t,"change._change",function(e){!this.parentNode||e.isSimulated||e.isTrigger||x.event.simulate("change",this.parentNode,e,!0)}),x._data(t,"changeBubbles",!0))}),t)},handle:function(e){var n=e.target;return this!==n||e.isSimulated||e.isTrigger||"radio"!==n.type&&"checkbox"!==n.type?e.handleObj.handler.apply(this,arguments):t},teardown:function(){return x.event.remove(this,"._change"),!Z.test(this.nodeName)}}),x.support.focusinBubbles||x.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){x.event.simulate(t,e.target,x.event.fix(e),!0)};x.event.special[t]={setup:function(){0===n++&&a.addEventListener(e,r,!0)},teardown:function(){0===--n&&a.removeEventListener(e,r,!0)}}}),x.fn.extend({on:function(e,n,r,i,o){var a,s;if("object"==typeof e){"string"!=typeof n&&(r=r||n,n=t);for(a in e)this.on(a,n,r,e[a],o);return this}if(null==r&&null==i?(i=n,r=n=t):null==i&&("string"==typeof n?(i=r,r=t):(i=r,r=n,n=t)),i===!1)i=ot;else if(!i)return this;return 1===o&&(s=i,i=function(e){return x().off(e),s.apply(this,arguments)},i.guid=s.guid||(s.guid=x.guid++)),this.each(function(){x.event.add(this,e,i,r,n)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,n,r){var i,o;if(e&&e.preventDefault&&e.handleObj)return i=e.handleObj,x(e.delegateTarget).off(i.namespace?i.origType+"."+i.namespace:i.origType,i.selector,i.handler),this;if("object"==typeof e){for(o in e)this.off(o,n,e[o]);return this}return(n===!1||"function"==typeof n)&&(r=n,n=t),r===!1&&(r=ot),this.each(function(){x.event.remove(this,e,r,n)})},trigger:function(e,t){return this.each(function(){x.event.trigger(e,t,this)})},triggerHandler:function(e,n){var r=this[0];return r?x.event.trigger(e,n,r,!0):t}});var st=/^.[^:#\[\.,]*$/,lt=/^(?:parents|prev(?:Until|All))/,ut=x.expr.match.needsContext,ct={children:!0,contents:!0,next:!0,prev:!0};x.fn.extend({find:function(e){var t,n=[],r=this,i=r.length;if("string"!=typeof e)return this.pushStack(x(e).filter(function(){for(t=0;i>t;t++)if(x.contains(r[t],this))return!0}));for(t=0;i>t;t++)x.find(e,r[t],n);return n=this.pushStack(i>1?x.unique(n):n),n.selector=this.selector?this.selector+" "+e:e,n},has:function(e){var t,n=x(e,this),r=n.length;return this.filter(function(){for(t=0;r>t;t++)if(x.contains(this,n[t]))return!0})},not:function(e){return this.pushStack(ft(this,e||[],!0))},filter:function(e){return this.pushStack(ft(this,e||[],!1))},is:function(e){return!!ft(this,"string"==typeof e&&ut.test(e)?x(e):e||[],!1).length},closest:function(e,t){var n,r=0,i=this.length,o=[],a=ut.test(e)||"string"!=typeof e?x(e,t||this.context):0;for(;i>r;r++)for(n=this[r];n&&n!==t;n=n.parentNode)if(11>n.nodeType&&(a?a.index(n)>-1:1===n.nodeType&&x.find.matchesSelector(n,e))){n=o.push(n);break}return this.pushStack(o.length>1?x.unique(o):o)},index:function(e){return e?"string"==typeof e?x.inArray(this[0],x(e)):x.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?x(e,t):x.makeArray(e&&e.nodeType?[e]:e),r=x.merge(this.get(),n);return this.pushStack(x.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function pt(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}x.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return x.dir(e,"parentNode")},parentsUntil:function(e,t,n){return x.dir(e,"parentNode",n)},next:function(e){return pt(e,"nextSibling")},prev:function(e){return pt(e,"previousSibling")},nextAll:function(e){return x.dir(e,"nextSibling")},prevAll:function(e){return x.dir(e,"previousSibling")},nextUntil:function(e,t,n){return x.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return x.dir(e,"previousSibling",n)},siblings:function(e){return x.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return x.sibling(e.firstChild)},contents:function(e){return x.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:x.merge([],e.childNodes)}},function(e,t){x.fn[e]=function(n,r){var i=x.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=x.filter(r,i)),this.length>1&&(ct[e]||(i=x.unique(i)),lt.test(e)&&(i=i.reverse())),this.pushStack(i)}}),x.extend({filter:function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?x.find.matchesSelector(r,e)?[r]:[]:x.find.matches(e,x.grep(t,function(e){return 1===e.nodeType}))},dir:function(e,n,r){var i=[],o=e[n];while(o&&9!==o.nodeType&&(r===t||1!==o.nodeType||!x(o).is(r)))1===o.nodeType&&i.push(o),o=o[n];return i},sibling:function(e,t){var n=[];for(;e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});function ft(e,t,n){if(x.isFunction(t))return x.grep(e,function(e,r){return!!t.call(e,r,e)!==n});if(t.nodeType)return x.grep(e,function(e){return e===t!==n});if("string"==typeof t){if(st.test(t))return x.filter(t,e,n);t=x.filter(t,e)}return x.grep(e,function(e){return x.inArray(e,t)>=0!==n})}function dt(e){var t=ht.split("|"),n=e.createDocumentFragment();if(n.createElement)while(t.length)n.createElement(t.pop());return n}var ht="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",gt=/ jQuery\d+="(?:null|\d+)"/g,mt=RegExp("<(?:"+ht+")[\\s/>]","i"),yt=/^\s+/,vt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,bt=/<([\w:]+)/,xt=/\s*$/g,At={option:[1,""],legend:[1,"
","
"],area:[1,"",""],param:[1,"",""],thead:[1,"","
"],tr:[2,"","
"],col:[2,"","
"],td:[3,"","
"],_default:x.support.htmlSerialize?[0,"",""]:[1,"X
","
"]},jt=dt(a),Dt=jt.appendChild(a.createElement("div"));At.optgroup=At.option,At.tbody=At.tfoot=At.colgroup=At.caption=At.thead,At.th=At.td,x.fn.extend({text:function(e){return x.access(this,function(e){return e===t?x.text(this):this.empty().append((this[0]&&this[0].ownerDocument||a).createTextNode(e))},null,e,arguments.length)},append:function(){return this.domManip(arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Lt(this,e);t.appendChild(e)}})},prepend:function(){return this.domManip(arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Lt(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return this.domManip(arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){var n,r=e?x.filter(e,this):this,i=0;for(;null!=(n=r[i]);i++)t||1!==n.nodeType||x.cleanData(Ft(n)),n.parentNode&&(t&&x.contains(n.ownerDocument,n)&&_t(Ft(n,"script")),n.parentNode.removeChild(n));return this},empty:function(){var e,t=0;for(;null!=(e=this[t]);t++){1===e.nodeType&&x.cleanData(Ft(e,!1));while(e.firstChild)e.removeChild(e.firstChild);e.options&&x.nodeName(e,"select")&&(e.options.length=0)}return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return x.clone(this,e,t)})},html:function(e){return x.access(this,function(e){var n=this[0]||{},r=0,i=this.length;if(e===t)return 1===n.nodeType?n.innerHTML.replace(gt,""):t;if(!("string"!=typeof e||Tt.test(e)||!x.support.htmlSerialize&&mt.test(e)||!x.support.leadingWhitespace&&yt.test(e)||At[(bt.exec(e)||["",""])[1].toLowerCase()])){e=e.replace(vt,"<$1>");try{for(;i>r;r++)n=this[r]||{},1===n.nodeType&&(x.cleanData(Ft(n,!1)),n.innerHTML=e);n=0}catch(o){}}n&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(){var e=x.map(this,function(e){return[e.nextSibling,e.parentNode]}),t=0;return this.domManip(arguments,function(n){var r=e[t++],i=e[t++];i&&(r&&r.parentNode!==i&&(r=this.nextSibling),x(this).remove(),i.insertBefore(n,r))},!0),t?this:this.remove()},detach:function(e){return this.remove(e,!0)},domManip:function(e,t,n){e=d.apply([],e);var r,i,o,a,s,l,u=0,c=this.length,p=this,f=c-1,h=e[0],g=x.isFunction(h);if(g||!(1>=c||"string"!=typeof h||x.support.checkClone)&&Nt.test(h))return this.each(function(r){var i=p.eq(r);g&&(e[0]=h.call(this,r,i.html())),i.domManip(e,t,n)});if(c&&(l=x.buildFragment(e,this[0].ownerDocument,!1,!n&&this),r=l.firstChild,1===l.childNodes.length&&(l=r),r)){for(a=x.map(Ft(l,"script"),Ht),o=a.length;c>u;u++)i=l,u!==f&&(i=x.clone(i,!0,!0),o&&x.merge(a,Ft(i,"script"))),t.call(this[u],i,u);if(o)for(s=a[a.length-1].ownerDocument,x.map(a,qt),u=0;o>u;u++)i=a[u],kt.test(i.type||"")&&!x._data(i,"globalEval")&&x.contains(s,i)&&(i.src?x._evalUrl(i.src):x.globalEval((i.text||i.textContent||i.innerHTML||"").replace(St,"")));l=r=null}return this}});function Lt(e,t){return x.nodeName(e,"table")&&x.nodeName(1===t.nodeType?t:t.firstChild,"tr")?e.getElementsByTagName("tbody")[0]||e.appendChild(e.ownerDocument.createElement("tbody")):e}function Ht(e){return e.type=(null!==x.find.attr(e,"type"))+"/"+e.type,e}function qt(e){var t=Et.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function _t(e,t){var n,r=0;for(;null!=(n=e[r]);r++)x._data(n,"globalEval",!t||x._data(t[r],"globalEval"))}function Mt(e,t){if(1===t.nodeType&&x.hasData(e)){var n,r,i,o=x._data(e),a=x._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)x.event.add(t,n,s[n][r])}a.data&&(a.data=x.extend({},a.data))}}function Ot(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!x.support.noCloneEvent&&t[x.expando]){i=x._data(t);for(r in i.events)x.removeEvent(t,r,i.handle);t.removeAttribute(x.expando)}"script"===n&&t.text!==e.text?(Ht(t).text=e.text,qt(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),x.support.html5Clone&&e.innerHTML&&!x.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Ct.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}x.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){x.fn[e]=function(e){var n,r=0,i=[],o=x(e),a=o.length-1;for(;a>=r;r++)n=r===a?this:this.clone(!0),x(o[r])[t](n),h.apply(i,n.get());return this.pushStack(i)}});function Ft(e,n){var r,o,a=0,s=typeof e.getElementsByTagName!==i?e.getElementsByTagName(n||"*"):typeof e.querySelectorAll!==i?e.querySelectorAll(n||"*"):t;if(!s)for(s=[],r=e.childNodes||e;null!=(o=r[a]);a++)!n||x.nodeName(o,n)?s.push(o):x.merge(s,Ft(o,n));return n===t||n&&x.nodeName(e,n)?x.merge([e],s):s}function Bt(e){Ct.test(e.type)&&(e.defaultChecked=e.checked)}x.extend({clone:function(e,t,n){var r,i,o,a,s,l=x.contains(e.ownerDocument,e);if(x.support.html5Clone||x.isXMLDoc(e)||!mt.test("<"+e.nodeName+">")?o=e.cloneNode(!0):(Dt.innerHTML=e.outerHTML,Dt.removeChild(o=Dt.firstChild)),!(x.support.noCloneEvent&&x.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||x.isXMLDoc(e)))for(r=Ft(o),s=Ft(e),a=0;null!=(i=s[a]);++a)r[a]&&Ot(i,r[a]);if(t)if(n)for(s=s||Ft(e),r=r||Ft(o),a=0;null!=(i=s[a]);a++)Mt(i,r[a]);else Mt(e,o);return r=Ft(o,"script"),r.length>0&&_t(r,!l&&Ft(e,"script")),r=s=i=null,o},buildFragment:function(e,t,n,r){var i,o,a,s,l,u,c,p=e.length,f=dt(t),d=[],h=0;for(;p>h;h++)if(o=e[h],o||0===o)if("object"===x.type(o))x.merge(d,o.nodeType?[o]:o);else if(wt.test(o)){s=s||f.appendChild(t.createElement("div")),l=(bt.exec(o)||["",""])[1].toLowerCase(),c=At[l]||At._default,s.innerHTML=c[1]+o.replace(vt,"<$1>")+c[2],i=c[0];while(i--)s=s.lastChild;if(!x.support.leadingWhitespace&&yt.test(o)&&d.push(t.createTextNode(yt.exec(o)[0])),!x.support.tbody){o="table"!==l||xt.test(o)?""!==c[1]||xt.test(o)?0:s:s.firstChild,i=o&&o.childNodes.length;while(i--)x.nodeName(u=o.childNodes[i],"tbody")&&!u.childNodes.length&&o.removeChild(u)}x.merge(d,s.childNodes),s.textContent="";while(s.firstChild)s.removeChild(s.firstChild);s=f.lastChild}else d.push(t.createTextNode(o));s&&f.removeChild(s),x.support.appendChecked||x.grep(Ft(d,"input"),Bt),h=0;while(o=d[h++])if((!r||-1===x.inArray(o,r))&&(a=x.contains(o.ownerDocument,o),s=Ft(f.appendChild(o),"script"),a&&_t(s),n)){i=0;while(o=s[i++])kt.test(o.type||"")&&n.push(o)}return s=null,f},cleanData:function(e,t){var n,r,o,a,s=0,l=x.expando,u=x.cache,c=x.support.deleteExpando,f=x.event.special;for(;null!=(n=e[s]);s++)if((t||x.acceptData(n))&&(o=n[l],a=o&&u[o])){if(a.events)for(r in a.events)f[r]?x.event.remove(n,r):x.removeEvent(n,r,a.handle); +u[o]&&(delete u[o],c?delete n[l]:typeof n.removeAttribute!==i?n.removeAttribute(l):n[l]=null,p.push(o))}},_evalUrl:function(e){return x.ajax({url:e,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0})}}),x.fn.extend({wrapAll:function(e){if(x.isFunction(e))return this.each(function(t){x(this).wrapAll(e.call(this,t))});if(this[0]){var t=x(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstChild&&1===e.firstChild.nodeType)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return x.isFunction(e)?this.each(function(t){x(this).wrapInner(e.call(this,t))}):this.each(function(){var t=x(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=x.isFunction(e);return this.each(function(n){x(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){x.nodeName(this,"body")||x(this).replaceWith(this.childNodes)}).end()}});var Pt,Rt,Wt,$t=/alpha\([^)]*\)/i,It=/opacity\s*=\s*([^)]*)/,zt=/^(top|right|bottom|left)$/,Xt=/^(none|table(?!-c[ea]).+)/,Ut=/^margin/,Vt=RegExp("^("+w+")(.*)$","i"),Yt=RegExp("^("+w+")(?!px)[a-z%]+$","i"),Jt=RegExp("^([+-])=("+w+")","i"),Gt={BODY:"block"},Qt={position:"absolute",visibility:"hidden",display:"block"},Kt={letterSpacing:0,fontWeight:400},Zt=["Top","Right","Bottom","Left"],en=["Webkit","O","Moz","ms"];function tn(e,t){if(t in e)return t;var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=en.length;while(i--)if(t=en[i]+n,t in e)return t;return r}function nn(e,t){return e=t||e,"none"===x.css(e,"display")||!x.contains(e.ownerDocument,e)}function rn(e,t){var n,r,i,o=[],a=0,s=e.length;for(;s>a;a++)r=e[a],r.style&&(o[a]=x._data(r,"olddisplay"),n=r.style.display,t?(o[a]||"none"!==n||(r.style.display=""),""===r.style.display&&nn(r)&&(o[a]=x._data(r,"olddisplay",ln(r.nodeName)))):o[a]||(i=nn(r),(n&&"none"!==n||!i)&&x._data(r,"olddisplay",i?n:x.css(r,"display"))));for(a=0;s>a;a++)r=e[a],r.style&&(t&&"none"!==r.style.display&&""!==r.style.display||(r.style.display=t?o[a]||"":"none"));return e}x.fn.extend({css:function(e,n){return x.access(this,function(e,n,r){var i,o,a={},s=0;if(x.isArray(n)){for(o=Rt(e),i=n.length;i>s;s++)a[n[s]]=x.css(e,n[s],!1,o);return a}return r!==t?x.style(e,n,r):x.css(e,n)},e,n,arguments.length>1)},show:function(){return rn(this,!0)},hide:function(){return rn(this)},toggle:function(e){return"boolean"==typeof e?e?this.show():this.hide():this.each(function(){nn(this)?x(this).show():x(this).hide()})}}),x.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Wt(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":x.support.cssFloat?"cssFloat":"styleFloat"},style:function(e,n,r,i){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var o,a,s,l=x.camelCase(n),u=e.style;if(n=x.cssProps[l]||(x.cssProps[l]=tn(u,l)),s=x.cssHooks[n]||x.cssHooks[l],r===t)return s&&"get"in s&&(o=s.get(e,!1,i))!==t?o:u[n];if(a=typeof r,"string"===a&&(o=Jt.exec(r))&&(r=(o[1]+1)*o[2]+parseFloat(x.css(e,n)),a="number"),!(null==r||"number"===a&&isNaN(r)||("number"!==a||x.cssNumber[l]||(r+="px"),x.support.clearCloneStyle||""!==r||0!==n.indexOf("background")||(u[n]="inherit"),s&&"set"in s&&(r=s.set(e,r,i))===t)))try{u[n]=r}catch(c){}}},css:function(e,n,r,i){var o,a,s,l=x.camelCase(n);return n=x.cssProps[l]||(x.cssProps[l]=tn(e.style,l)),s=x.cssHooks[n]||x.cssHooks[l],s&&"get"in s&&(a=s.get(e,!0,r)),a===t&&(a=Wt(e,n,i)),"normal"===a&&n in Kt&&(a=Kt[n]),""===r||r?(o=parseFloat(a),r===!0||x.isNumeric(o)?o||0:a):a}}),e.getComputedStyle?(Rt=function(t){return e.getComputedStyle(t,null)},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),l=s?s.getPropertyValue(n)||s[n]:t,u=e.style;return s&&(""!==l||x.contains(e.ownerDocument,e)||(l=x.style(e,n)),Yt.test(l)&&Ut.test(n)&&(i=u.width,o=u.minWidth,a=u.maxWidth,u.minWidth=u.maxWidth=u.width=l,l=s.width,u.width=i,u.minWidth=o,u.maxWidth=a)),l}):a.documentElement.currentStyle&&(Rt=function(e){return e.currentStyle},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),l=s?s[n]:t,u=e.style;return null==l&&u&&u[n]&&(l=u[n]),Yt.test(l)&&!zt.test(n)&&(i=u.left,o=e.runtimeStyle,a=o&&o.left,a&&(o.left=e.currentStyle.left),u.left="fontSize"===n?"1em":l,l=u.pixelLeft+"px",u.left=i,a&&(o.left=a)),""===l?"auto":l});function on(e,t,n){var r=Vt.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function an(e,t,n,r,i){var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;for(;4>o;o+=2)"margin"===n&&(a+=x.css(e,n+Zt[o],!0,i)),r?("content"===n&&(a-=x.css(e,"padding"+Zt[o],!0,i)),"margin"!==n&&(a-=x.css(e,"border"+Zt[o]+"Width",!0,i))):(a+=x.css(e,"padding"+Zt[o],!0,i),"padding"!==n&&(a+=x.css(e,"border"+Zt[o]+"Width",!0,i)));return a}function sn(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=Rt(e),a=x.support.boxSizing&&"border-box"===x.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=Wt(e,t,o),(0>i||null==i)&&(i=e.style[t]),Yt.test(i))return i;r=a&&(x.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+an(e,t,n||(a?"border":"content"),r,o)+"px"}function ln(e){var t=a,n=Gt[e];return n||(n=un(e,t),"none"!==n&&n||(Pt=(Pt||x("'); + frame.attr('src', url); + $('body').append(frame); +} + +function uploadFile(uri, input, callback) { + if (!window.FormData) { + showErrorMessage("Your browser doesn't implement this function!");s + callback(); + } + + var formData = new FormData(); + var files = input[0].files; + formData.append('files', files[0], files[0].name); + + ajax('POST', uri, formData, callback); +} + + + /* apply button */ + +function showApply() { + var applyButton = $('#applyButton'); + + applyButton.html('Apply'); + applyButton.css('display', 'inline-block'); + applyButton.removeClass('progress'); + setTimeout(function () { + applyButton.css('opacity', '1'); + }, 10); +} + +function hideApply() { + var applyButton = $('#applyButton'); + + applyButton.css('opacity', '0'); + applyButton.removeClass('progress'); + + setTimeout(function () { + applyButton.css('display', 'none'); + }, 500); +} + +function isApplyVisible() { + var applyButton = $('#applyButton'); + + return applyButton.is(':visible'); +} + +function doApply() { + if (!configUiValid()) { + runAlertDialog('Make sure all the configuration options are valid!'); + return; + } + + function actualApply() { + /* gather the affected motion instances */ + var affectedInstances = {}; + Object.keys(pushConfigs).forEach(function (key) { + var config = pushConfigs[key]; + if (key === 'main') { + return; + } + + var instance; + if (config.proto == 'netcam' || config.proto == 'v4l2') { + instance = ''; + } + else if (config.proto == 'motioneye') { /* motioneye */ + instance = config.host || ''; + if (config.port) { + instance += ':' + config.port; + } + } + + affectedInstances[instance] = true; + }); + affectedInstances = Object.keys(affectedInstances); + + /* compute the affected camera ids */ + var cameraIdsByInstance = getCameraIdsByInstance(); + var affectedCameraIds = []; + + affectedInstances.forEach(function (instance) { + affectedCameraIds = affectedCameraIds.concat(cameraIdsByInstance[instance] || []); + }); + + beginProgress(affectedCameraIds); + affectedCameraIds.forEach(function (cameraId) { + refreshDisabled[cameraId] |= 0; + refreshDisabled[cameraId]++; + }); + + ajax('POST', baseUri + 'config/0/set/', pushConfigs, function (data) { + affectedCameraIds.forEach(function (cameraId) { + refreshDisabled[cameraId]--; + }); + + if (data == null || data.error) { + endProgress(); + showErrorMessage(data && data.error); + return; + } + + if (data.reboot) { + var count = 0; + function checkServerReboot() { + ajax('GET', baseUri + 'config/0/get/', null, + function () { + window.location.reload(true); + }, + function () { + if (count < 25) { + count += 1; + setTimeout(checkServerReboot, 2000); + } + else { + window.location.reload(true); + } + } + ); + } + + setTimeout(checkServerReboot, 15000); + + return; + } + + if (data.reload) { + window.location.reload(true); + return; + } + + /* update the camera name in the device select + * and frame title bar */ + Object.keys(pushConfigs).forEach(function (key) { + var config = pushConfigs[key]; + if (config.key !== 'main') { + $('#cameraSelect').find('option[value=' + key + ']').html(config.name); + } + + $('#camera' + key).find('span.camera-name').html(config.name); + }); + + pushConfigs = {}; + pushConfigReboot = false; + endProgress(); + recreateCameraFrames(); /* a camera could have been disabled */ + }); + } + + if (pushConfigReboot) { + runConfirmDialog('This will reboot the system. Continue?', function () { + actualApply(); + }); + } + else { + actualApply(); + } +} + +function doShutDown() { + runConfirmDialog('Really shut down?', function () { + ajax('POST', baseUri + 'power/shutdown/'); + setTimeout(function () { + refreshInterval = 1000000; + showModalDialog(''); + + function checkServer() { + ajax('GET', baseUri, null, + function () { + setTimeout(checkServer, 1000); + }, + function () { + showModalDialog('Powered Off'); + setTimeout(function () { + $('div.modal-glass').animate({'opacity': '1', 'background-color': '#212121'}, 200); + },100); + }, + 10000 /* timeout = 10s */ + ); + } + + checkServer(); + }, 10); + }); +} + +function doReboot() { + runConfirmDialog('Really reboot?', function () { + ajax('POST', baseUri + 'power/reboot/'); + setTimeout(function () { + refreshInterval = 1000000; + showModalDialog(''); + var shutDown = false; + + function checkServer() { + ajax('GET', baseUri, null, + function () { + if (!shutDown) { + setTimeout(checkServer, 1000); + } + else { + runAlertDialog('The system has been rebooted!', function () { + window.location.reload(true); + }); + } + }, + function () { + shutDown = true; /* the first error indicates the system was shut down */ + setTimeout(checkServer, 1000); + }, + 5 * 1000 /* timeout = 5s */ + ); + } + + checkServer(); + }, 10); + }); +} + +function doRemCamera() { + if (Object.keys(pushConfigs).length) { + return runAlertDialog('Please apply the modified settings first!'); + } + + var cameraId = $('#cameraSelect').val(); + if (cameraId == null || cameraId === 'add') { + runAlertDialog('No camera to remove!'); + return; + } + + var deviceName = $('#cameraSelect').find('option[value=' + cameraId + ']').text(); + + runConfirmDialog('Remove camera ' + deviceName + '?', function () { + /* disable further refreshing of this camera */ + var img = $('div.camera-frame#camera' + cameraId).find('img.camera'); + if (img.length) { + img[0].loading = 1; + } + + beginProgress(); + ajax('POST', baseUri + 'config/' + cameraId + '/rem/', null, function (data) { + if (data == null || data.error) { + endProgress(); + showErrorMessage(data && data.error); + return; + } + + fetchCurrentConfig(endProgress); + }); + }); +} + +function doUpdate() { + if (Object.keys(pushConfigs).length) { + return runAlertDialog('Please apply the modified settings first!'); + } + + showModalDialog(''); + ajax('GET', baseUri + 'update/', null, function (data) { + if (data.update_version == null) { + runAlertDialog('motionEye is up to date (current version: ' + data.current_version + ')'); + } + else { + runConfirmDialog('New version available: ' + data.update_version + '. Update?', function () { + refreshInterval = 1000000; + showModalDialog('
Updating. This may take a few minutes.
'); + ajax('POST', baseUri + 'update/?version=' + data.update_version, null, function () { + var count = 0; + function checkServer() { + ajax('GET', baseUri + 'config/0/get/', null, + function () { + runAlertDialog('motionEye was successfully updated!', function () { + window.location.reload(true); + }); + }, + function () { + if (count < 60) { + count += 1; + setTimeout(checkServer, 5000); + } + else { + runAlertDialog('Update failed!', function () { + window.location.reload(true); + }); + } + } + ); + } + + setTimeout(checkServer, 10000); + + }, function (e) { /* error */ + runAlertDialog('The update process has failed!', function () { + window.location.reload(true); + }); + }); + + return false; /* prevents hiding the modal container */ + }); + } + }); +} + +function doBackup() { + downloadFile('config/backup/'); +} + +function doRestore() { + var content = + $('
' + + '' + + '' + + '' + + '' + + '' + + '
Backup File
?
'); + + /* collect ui widgets */ + var fileInput = content.find('#fileInput'); + + /* make validators */ + makeFileValidator(fileInput, true); + + function uiValid() { + /* re-validate all the validators */ + content.find('.validator').each(function () { + this.validate(); + }); + + var valid = true; + var query = content.find('input, select'); + query.each(function () { + if (this.invalid) { + valid = false; + return false; + } + }); + + return valid; + } + + runModalDialog({ + title: 'Restore Configuration', + closeButton: true, + buttons: 'okcancel', + content: content, + onOk: function () { + if (!uiValid(true)) { + return false; + } + + refreshInterval = 1000000; + + setTimeout(function () { + showModalDialog('
Restoring configuration...
'); + uploadFile(baseUri + 'config/restore/', fileInput, function (data) { + if (data && data.ok) { + var count = 0; + function checkServer() { + ajax('GET', baseUri + 'config/0/get/', null, + function () { + runAlertDialog('The configuration has been restored!', function () { + window.location.reload(true); + }); + }, + function () { + if (count < 25) { + count += 1; + setTimeout(checkServer, 2000); + } + else { + runAlertDialog('Failed to restore the configuration!', function () { + window.location.reload(true); + }); + } + } + ); + } + + if (data.reboot) { + setTimeout(checkServer, 10000); + } + else { + setTimeout(function () { + window.location.reload(); + }, 5000); + } + } + else { + hideModalDialog(); + showErrorMessage('Failed to restore the configuration!'); + } + }); + }, 10); + } + }); +} + +function doDownloadZipped(cameraId, groupKey) { + showModalDialog('', null, null, true); + ajax('GET', baseUri + 'picture/' + cameraId + '/zipped/' + groupKey + '/', null, function (data) { + if (data.error) { + hideModalDialog(); /* progress */ + showErrorMessage(data.error); + } + else { + hideModalDialog(); /* progress */ + downloadFile('picture/' + cameraId + '/zipped/' + groupKey + '/?key=' + data.key); + } + }); +} + +function doDeleteFile(uri, callback) { + var url = window.location.href; + var parts = url.split('/'); + url = parts.slice(0, 3).join('/') + uri; + + runConfirmDialog('Really delete this file?', function () { + showModalDialog('', null, null, true); + ajax('POST', url, null, function (data) { + hideModalDialog(); /* progress */ + hideModalDialog(); /* confirm */ + + if (data == null || data.error) { + showErrorMessage(data && data.error); + return; + } + + if (callback) { + callback(); + } + }); + + return false; + }, {stack: true}); +} + +function doDeleteAllFiles(mediaType, cameraId, groupKey, callback) { + runConfirmDialog('Really delete all ' + mediaType + 's in ' + groupKey + '?', function () { + showModalDialog('', null, null, true); + ajax('POST', baseUri + mediaType + '/' + cameraId + '/delete_all/' + groupKey + '/', null, function (data) { + hideModalDialog(); /* progress */ + hideModalDialog(); /* confirm */ + + if (data == null || data.error) { + showErrorMessage(data && data.error); + return; + } + + if (callback) { + callback(); + } + }); + + return false; + }, {stack: true}); +} + + + /* fetch & push */ + +function fetchCurrentConfig(onFetch) { + function fetchCameraList() { + /* fetch the camera list */ + ajax('GET', baseUri + 'config/list/', null, function (data) { + if (data == null || data.error) { + showErrorMessage(data && data.error); + data = {cameras: []}; + if (onFetch) { + onFetch(null); + } + } + + initialConfigFetched = true; + + var i, cameras = data.cameras; + + if (isAdmin()) { + var cameraSelect = $('#cameraSelect'); + cameraSelect.html(''); + for (i = 0; i < cameras.length; i++) { + var camera = cameras[i]; + cameraSelect.append(''); + } + cameraSelect.append(''); + + var enabledCameras = cameras.filter(function (camera) {return camera['enabled'];}); + if (enabledCameras.length > 0) { /* prefer the first enabled camera */ + cameraSelect[0].selectedIndex = cameras.indexOf(enabledCameras[0]); + fetchCurrentCameraConfig(onFetch); + } + else if (cameras.length) { /* only disabled cameras */ + cameraSelect[0].selectedIndex = 0; + fetchCurrentCameraConfig(onFetch); + } + else { /* no camera at all */ + cameraSelect[0].selectedIndex = -1; + + if (onFetch) { + onFetch(data); + } + } + + updateConfigUi(); + } + else { /* normal user */ + if (!cameras.length) { + /* normal user with no cameras doesn't make too much sense - force login */ + doLogout(); + } + + if (onFetch) { + onFetch(data); + } + } + + var mainLoadingProgressImg = $('img.main-loading-progress'); + if (mainLoadingProgressImg.length) { + mainLoadingProgressImg.animate({'opacity': 0}, 200, function () { + recreateCameraFrames(cameras); + mainLoadingProgressImg.remove(); + }); + } + else { + recreateCameraFrames(cameras); + } + }); + } + + /* add a progress indicator */ + $('div.page-container').append(''); + + if (isAdmin()) { + /* fetch the main configuration */ + ajax('GET', baseUri + 'config/main/get/', null, function (data) { + if (data == null || data.error) { + showErrorMessage(data && data.error); + return; + } + + dict2MainUi(data); + fetchCameraList(); + }); + } + else { + fetchCameraList(); + } +} + +function fetchCurrentCameraConfig(onFetch) { + var cameraId = $('#cameraSelect').val(); + if (cameraId != null) { + ajax('GET', baseUri + 'config/' + cameraId + '/get/?force=true', null, function (data) { + if (data == null || data.error) { + showErrorMessage(data && data.error); + dict2CameraUi(null); + if (onFetch) { + onFetch(null); + } + + return; + } + + dict2CameraUi(data); + if (onFetch) { + onFetch(data); + } + }); + } + else { + dict2CameraUi({}); + if (onFetch) { + onFetch({}); + } + } +} + +function pushMainConfig(reboot) { + if (!initialConfigFetched) { + return; + } + + var mainConfig = mainUi2Dict(); + + pushConfigReboot = pushConfigReboot || reboot; + pushConfigs['main'] = mainConfig; + if (!isApplyVisible()) { + showApply(); + } +} + +function pushCameraConfig(reboot) { + if (!initialConfigFetched) { + return; + } + + var cameraId = $('#cameraSelect').val(); + if (!cameraId) { + return; /* event triggered without a selected camera */ + } + + var cameraConfig = cameraUi2Dict(); + + pushConfigReboot = pushConfigReboot || reboot; + pushConfigs[cameraId] = cameraConfig; + if (!isApplyVisible()) { + showApply(); + } + + /* also update the config stored in the camera frame div */ + var cameraFrame = $('div.camera-frame#camera' + cameraId); + if (cameraFrame.length) { + Object.update(cameraFrame[0].config, cameraConfig); + } +} + +function pushPreview(control) { + var cameraId = $('#cameraSelect').val(); + + var brightness = $('#brightnessSlider').val(); + var contrast= $('#contrastSlider').val(); + var saturation = $('#saturationSlider').val(); + var hue = $('#hueSlider').val(); + + var data = {}; + + if (brightness !== '' && (!control || control == 'brightness')) { + data.brightness = brightness; + } + + if (contrast !== '' && (!control || control == 'contrast')) { + data.contrast = contrast; + } + + if (saturation !== '' && (!control || control == 'saturation')) { + data.saturation = saturation; + } + + if (hue !== '' && (!control || control == 'hue')) { + data.hue = hue; + } + + refreshDisabled[cameraId] |= 0; + refreshDisabled[cameraId]++; + + ajax('POST', baseUri + 'config/' + cameraId + '/set_preview/', data, function (data) { + refreshDisabled[cameraId]--; + + if (data == null || data.error) { + showErrorMessage(data && data.error); + return; + } + }); +} + +function getCameraIdsByInstance() { + /* a motion instance is identified by the (host, port) pair; + * the local instance has both the host and the port set to empty string */ + + var cameraIdsByInstance = {}; + $('div.camera-frame').each(function () { + var instance; + if (this.config.proto == 'netcam' || this.config.proto == 'v4l2') { + instance = ''; + } + else if (this.config.proto == 'motioneye') { + instance = this.config.host || ''; + if (this.config.port) { + instance += ':' + this.config.port; + } + } + else { /* assuming simple mjpeg camera */ + return; + } + + (cameraIdsByInstance[instance] = cameraIdsByInstance[instance] || []).push(this.config.id); + }); + + return cameraIdsByInstance; +} + + + /* dialogs */ + +function runAlertDialog(message, onOk, options) { + var params = { + title: message, + buttons: 'ok', + onOk: onOk + }; + + if (options) { + Object.update(params, options); + } + + runModalDialog(params); +} + +function runConfirmDialog(message, onYes, options) { + var params = { + title: message, + buttons: 'yesno', + onYes: onYes + }; + + if (options) { + Object.update(params, options); + } + + runModalDialog(params); +} + +function runLoginDialog(retry) { + /* a workaround so that browsers will remember the credentials */ + var tempFrame = $(''); + $('body').append(tempFrame); + + var form = + $('
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
'); + + var usernameEntry = form.find('#usernameEntry'); + var passwordEntry = form.find('#passwordEntry'); + var errorTd = form.find('td.login-dialog-error'); + + if (window._loginRetry) { + errorTd.css('display', 'table-cell'); + errorTd.html('Invalid credentials.'); + } + + var params = { + title: 'Login', + content: form, + buttons: [ + {caption: 'Cancel', isCancel: true, click: function () { + tempFrame.remove(); + }}, + {caption: 'Login', isDefault: true, click: function () { + window.username = usernameEntry.val(); + window.password = passwordEntry.val(); + window._loginDialogSubmitted = true; + + setCookie('username', window.username); + + form.submit(); + setTimeout(function () { + tempFrame.remove(); + }, 5000); + + if (retry) { + retry(); + } + }} + ], + }; + + runModalDialog(params); +} + +function runPictureDialog(entries, pos, mediaType) { + var content = $('
'); + + var img = $(''); + content.append(img); + + var prevArrow = $('
'); + content.append(prevArrow); + + var nextArrow = $('
'); + content.append(nextArrow); + + var progressImg = $(''); + + function updatePicture() { + var entry = entries[pos]; + + var windowWidth = $(window).width(); + var windowHeight = $(window).height(); + var widthCoef = windowWidth < 1000 ? 0.8 : 0.5; + var heightCoef = 0.75; + + var width = parseInt(windowWidth * widthCoef); + var height = parseInt(windowHeight * heightCoef); + + prevArrow.css('display', 'none'); + nextArrow.css('display', 'none'); + img.parent().append(progressImg); + updateModalDialogPosition(); + progressImg.css('left', (img.parent().width() - progressImg.width()) / 2); + progressImg.css('top', (img.parent().height() - progressImg.height()) / 2); + + img.attr('src', addAuthParams('GET', baseUri + mediaType + '/' + entry.cameraId + '/preview' + entry.path)); + img.load(function () { + var aspectRatio = this.naturalWidth / this.naturalHeight; + var sizeWidth = width * width / aspectRatio; + var sizeHeight = height * aspectRatio * height; + + if (sizeWidth < sizeHeight) { + img.width(width); + } + else { + img.height(height); + } + updateModalDialogPosition(); + prevArrow.css('display', pos > 0 ? '' : 'none'); + nextArrow.css('display', pos < entries.length - 1 ? '' : 'none'); + progressImg.remove(); + }); + + $('div.modal-container').find('span.modal-title:last').html(entry.name); + updateModalDialogPosition(); + } + + prevArrow.click(function () { + if (pos > 0) { + pos--; + } + + updatePicture(); + }); + + nextArrow.click(function () { + if (pos < entries.length - 1) { + pos++; + } + + updatePicture(); + }); + + function bodyKeyDown(e) { + switch (e.which) { + case 37: + if (prevArrow.is(':visible')) { + prevArrow.click(); + } + break; + + case 39: + if (nextArrow.is(':visible')) { + nextArrow.click(); + } + break; + } + } + + $('body').on('keydown', bodyKeyDown); + + img.load(updateModalDialogPosition); + + runModalDialog({ + title: ' ', + closeButton: true, + buttons: [ + {caption: 'Close'}, + {caption: 'Download', isDefault: true, click: function () { + var entry = entries[pos]; + downloadFile(mediaType + '/' + entry.cameraId + '/download' + entry.path); + + return false; + }} + ], + content: content, + stack: true, + onShow: updatePicture, + onClose: function () { + $('body').off('keydown', bodyKeyDown); + } + }); +} + +function runAddCameraDialog() { + if (!$('#motionEyeSwitch')[0].checked) { + return runAlertDialog('Please enable motionEye first!'); + } + + if (Object.keys(pushConfigs).length) { + return runAlertDialog('Please apply the modified settings first!'); + } + + var content = + $('' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
Camera Type?
URL?
Username?
Password?
Camera?
'); + + /* collect ui widgets */ + var typeSelect = content.find('#typeSelect'); + var urlEntry = content.find('#urlEntry'); + var usernameEntry = content.find('#usernameEntry'); + var passwordEntry = content.find('#passwordEntry'); + var addCameraSelect = content.find('#addCameraSelect'); + var addCameraInfo = content.find('#addCameraInfo'); + var cameraMsgLabel = content.find('#cameraMsgLabel'); + + /* make validators */ + makeUrlValidator(urlEntry, true); + makeTextValidator(usernameEntry, false); + makeTextValidator(typeSelect, false); + makeComboValidator(addCameraSelect, true); + + /* ui interaction */ + function updateUi() { + content.find('tr.v4l2, tr.motioneye, tr.netcam, tr.mjpeg').css('display', 'none'); + + if (typeSelect.val() == 'motioneye') { + content.find('tr.motioneye').css('display', 'table-row'); + usernameEntry.val('admin'); + usernameEntry.attr('readonly', 'readonly'); + addCameraInfo.html( + 'Remote motionEye cameras are cameras installed behind another motionEye server. ' + + 'Adding them here will allow you to view and manage them remotely.'); + } + else if (typeSelect.val() == 'netcam') { + usernameEntry.removeAttr('readonly'); + + /* make sure there is one trailing slash so that + * an URI can be detected */ + var url = urlEntry.val().trim(); + var m = url.match(new RegExp('/', 'g')); + if (m && m.length < 3 && !url.endsWith('/')) { + urlEntry.val(url + '/'); + } + + content.find('tr.netcam').css('display', 'table-row'); + addCameraInfo.html( + 'Network cameras (or IP cameras) are devices that natively stream RTSP or MJPEG videos or plain JPEG images. ' + + "Consult your device's manual to find out the correct RTSP, MJPEG or JPEG URL."); + } + else if (typeSelect.val() == 'mjpeg') { + usernameEntry.removeAttr('readonly'); + + /* make sure there is one trailing slash so that + * an URI can be detected */ + var url = urlEntry.val().trim(); + var m = url.match(new RegExp('/', 'g')); + if (m && m.length < 3 && !url.endsWith('/')) { + urlEntry.val(url + '/'); + } + + content.find('tr.mjpeg').css('display', 'table-row'); + addCameraInfo.html( + 'Adding your device as a simple MJPEG camera instead of as a network camera will improve the framerate, ' + + 'but no motion detection, picture capturing or movie recording will be available for it. ' + + 'The camera must be accessible to both your server and your browser. ' + + 'This type of camera is not compatible with Internet Explorer.'); + } + else { /* assuming v4l2 */ + content.find('tr.v4l2').css('display', 'table-row'); + addCameraInfo.html( + 'Local cameras are camera devices that are connected directly to your motionEye system. ' + + 'These are usually USB webcams or board-specific cameras.'); + } + + updateModalDialogPosition(); + + /* re-validate all the validators */ + content.find('.validator').each(function () { + this.validate(); + }); + + if (uiValid()) { + listCameras(); + } + } + + function uiValid(includeCameraSelect) { + var query = content.find('input, select'); + if (!includeCameraSelect) { + query = query.not('#addCameraSelect'); + } + else { + if (cameraMsgLabel.html() || !addCameraSelect.val()) { + return false; + } + } + + /* re-validate all the validators */ + content.find('.validator').each(function () { + this.validate(); + }); + + var valid = true; + query.each(function () { + if (this.invalid) { + valid = false; + return false; + } + }); + + return valid; + } + + function splitCameraUrl(url) { + var parts = url.split('://'); + var scheme = parts[0]; + var index = parts[1].indexOf('/'); + var host = null; + var uri = ''; + if (index >= 0) { + host = parts[1].substring(0, index); + uri = parts[1].substring(index); + } + else { + host = parts[1]; + } + + var port = ''; + parts = host.split(':'); + if (parts.length >= 2) { + host = parts[0]; + port = parts[1]; + } + + if (uri == '') { + uri = '/'; + } + + return { + scheme: scheme, + host: host, + port: port, + uri: uri + }; + } + + function listCameras() { + var progress = $('
'); + + addCameraSelect.html(''); + addCameraSelect.hide(); + addCameraSelect.parent().find('div').remove(); /* remove any previous progress div */ + addCameraSelect.before(progress); + + var data = {}; + if (urlEntry.is(':visible') && urlEntry.val()) { + data = splitCameraUrl(urlEntry.val()); + } + data.username = usernameEntry.val(); + data.password = passwordEntry.val(); + data.proto = typeSelect.val(); + + cameraMsgLabel.html(''); + + ajax('GET', baseUri + 'config/list/', data, function (data) { + progress.remove(); + + if (data == null || data.error) { + cameraMsgLabel.html(data && data.error); + + return; + } + + if (data.error || !data.cameras) { + return; + } + + data.cameras.forEach(function (info) { + var option = $(''); + option[0]._extra_attrs = {}; + Object.keys(info).forEach(function (key) { + if (key == 'id' || key == 'name') { + return; + } + + var value = info[key]; + option[0]._extra_attrs[key] = value; + }); + + addCameraSelect.append(option); + }); + + if (!data.cameras || !data.cameras.length) { + addCameraSelect.append(''); + } + + addCameraSelect.show(); + addCameraSelect[0].validate(); + }); + } + + typeSelect.change(function () { + addCameraSelect.html(''); + }); + + typeSelect.change(updateUi); + urlEntry.change(updateUi); + usernameEntry.change(updateUi); + passwordEntry.change(updateUi); + updateUi(); + + runModalDialog({ + title: 'Add Camera...', + closeButton: true, + buttons: 'okcancel', + content: content, + onOk: function () { + if (!uiValid(true)) { + return false; + } + + var data = {}; + + if (typeSelect.val() == 'motioneye') { + data = splitCameraUrl(urlEntry.val()); + data.proto = 'motioneye'; + data.username = usernameEntry.val(); + data.password = passwordEntry.val(); + data.remote_camera_id = addCameraSelect.val(); + } + else if (typeSelect.val() == 'netcam') { + data = splitCameraUrl(urlEntry.val()); + 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()); + data.username = usernameEntry.val(); + data.password = passwordEntry.val(); + data.proto = 'mjpeg'; + } + else { /* assuming v4l2 */ + data.proto = 'v4l2'; + data.uri = addCameraSelect.val(); + } + + /* add all extra attributes */ + var option = addCameraSelect.find('option:eq(' + addCameraSelect[0].selectedIndex + ')')[0]; + Object.keys(option._extra_attrs).forEach(function (key) { + var value = option._extra_attrs[key]; + data[key] = value; + }); + + beginProgress(); + ajax('POST', baseUri + 'config/add/', data, function (data) { + endProgress(); + + if (data == null || data.error) { + showErrorMessage(data && data.error); + return; + } + + var cameraOption = $('#cameraSelect').find('option[value=add]'); + cameraOption.before(''); + $('#cameraSelect').val(data.id).change(); + recreateCameraFrames(); + }); + } + }); +} + +function runTimelapseDialog(cameraId, groupKey, group) { + var content = + $('' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
Group' + groupKey + '
Include a picture taken every' + + '' + + '?
Movie framerate?
'); + + var intervalSelect = content.find('#intervalSelect'); + var framerateSlider = content.find('#framerateSlider'); + var timelapseWarning = content.find('td.timelapse-warning'); + + if (group.length > 1440) { /* one day worth of pictures, taken 1 minute apart */ + timelapseWarning.html('Given the large number of pictures, creating your timelapse might take a while!'); + timelapseWarning.css('display', 'table-cell'); + } + + makeSlider(framerateSlider, 1, 100, 0, [ + {value: 1, label: '1'}, + {value: 20, label: '20'}, + {value: 40, label: '40'}, + {value: 60, label: '60'}, + {value: 80, label: '80'}, + {value: 100, label: '100'} + ], null, 0); + + intervalSelect.val(60); + framerateSlider.val(20).each(function () {this.update()}); + + runModalDialog({ + title: 'Create Timelapse Movie', + closeButton: true, + buttons: 'okcancel', + content: content, + onOk: function () { + var progressBar = $('
'); + makeProgressBar(progressBar); + + runModalDialog({ + title: 'Creating Timelapse Movie...', + content: progressBar, + stack: true, + noKeys: true + }); + + var url = baseUri + 'picture/' + cameraId + '/timelapse/' + groupKey + '/'; + var data = {interval: intervalSelect.val(), framerate: framerateSlider.val()}; + var first = true; + + function checkTimelapse() { + var actualUrl = url; + if (!first) { + actualUrl += '?check=true'; + } + + ajax('GET', actualUrl, data, function (data) { + if (data == null || data.error) { + hideModalDialog(); /* progress */ + hideModalDialog(); /* timelapse dialog */ + showErrorMessage(data && data.error); + return; + } + + if (data.progress != -1 && first) { + showPopupMessage('A timelapse movie is already being created.'); + } + + if (data.progress == -1 && !first && !data.key) { + hideModalDialog(); /* progress */ + hideModalDialog(); /* timelapse dialog */ + showErrorMessage('The timelapse movie could not be created.'); + return; + } + + if (data.progress == -1) { + data.progress = 0; + } + + if (data.key) { + progressBar[0].setProgress(100); + progressBar[0].setText('100%'); + + setTimeout(function () { + hideModalDialog(); /* progress */ + hideModalDialog(); /* timelapse dialog */ + downloadFile('picture/' + cameraId + '/timelapse/' + groupKey + '/?key=' + data.key); + }, 500); + } + else { + progressBar[0].setProgress(data.progress * 100); + progressBar[0].setText(parseInt(data.progress * 100) + '%'); + setTimeout(checkTimelapse, 1000); + } + + first = false; + }); + } + + checkTimelapse(); + + return false; + }, + stack: true + }); +} + +function runMediaDialog(cameraId, mediaType) { + var dialogDiv = $('
'); + var mediaListDiv = $('
'); + var groupsDiv = $('
'); + var buttonsDiv = $('
'); + + var groups = {}; + var groupKey = null; + + dialogDiv.append(groupsDiv); + dialogDiv.append(mediaListDiv); + dialogDiv.append(buttonsDiv); + + /* add a temporary div to compute 3em in px */ + var tempDiv = $('
'); + $('div.modal-container').append(tempDiv); + var height = tempDiv.height(); + tempDiv.remove(); + + function showGroup(key) { + groupKey = key; + + if (mediaListDiv.find('img.media-list-progress').length) { + return; /* already in progress of loading */ + } + + /* (re)set the current state of the group buttons */ + groupsDiv.find('div.media-dialog-group-button').each(function () { + var $this = $(this); + if (this.key == key) { + $this.addClass('current'); + } + else { + $this.removeClass('current'); + } + }); + + var mediaListByName = {}; + var entries = groups[key]; + + /* cleanup the media list */ + mediaListDiv.children('div.media-list-entry').detach(); + mediaListDiv.html(''); + + function addEntries() { + /* add the entries to the media list */ + entries.forEach(function (entry, i) { + var entryDiv = entry.div; + var detailsDiv = null; + + if (!entryDiv) { + entryDiv = $('
'); + + var previewImg = $(''); + entryDiv.append(previewImg); + previewImg[0]._src = addAuthParams('GET', baseUri + mediaType + '/' + cameraId + '/preview' + entry.path + '?height=' + height); + + var downloadButton = $('
Download
'); + entryDiv.append(downloadButton); + + var deleteButton = $('
Delete
'); + if (isAdmin()) { + entryDiv.append(deleteButton); + } + + var nameDiv = $('
' + entry.name + '
'); + entryDiv.append(nameDiv); + + detailsDiv = $('
'); + entryDiv.append(detailsDiv); + + downloadButton.click(function () { + downloadFile(mediaType + '/' + cameraId + '/download' + entry.path); + return false; + }); + + deleteButton.click(function () { + doDeleteFile(baseUri + mediaType + '/' + cameraId + '/delete' + entry.path, function () { + entryDiv.remove(); + entries.splice(i, 1); /* remove entry from group */ + + /* update text on group button */ + groupsDiv.find('div.media-dialog-group-button').each(function () { + var $this = $(this); + if (this.key == groupKey) { + var text = this.innerHTML; + text = text.substring(0, text.lastIndexOf(' ')); + text += ' (' + entries.length + ')'; + this.innerHTML = text; + } + }); + }); + + return false; + }); + + entryDiv.click(function () { + var pos = entries.indexOf(entry); + runPictureDialog(entries, pos, mediaType); + }); + + entry.div = entryDiv; + } + else { + detailsDiv = entry.div.find('div.media-list-entry-details'); + } + + var momentSpan = $('' + entry.momentStr + ', '); + var momentShortSpan = $('' + entry.momentStrShort + ''); + var sizeSpan = $('' + entry.sizeStr + ''); + detailsDiv.empty(); + detailsDiv.append(momentSpan); + detailsDiv.append(momentShortSpan); + detailsDiv.append(sizeSpan); + mediaListDiv.append(entryDiv); + }); + + /* trigger a scroll event */ + mediaListDiv.scroll(); + } + + /* if details are already fetched, simply add the entries and return */ + if (entries[0].timestamp) { + return addEntries(); + } + + var previewImg = $(''); + mediaListDiv.append(previewImg); + + var url = baseUri + mediaType + '/' + cameraId + '/list/?prefix=' + (key || 'ungrouped'); + ajax('GET', url, null, function (data) { + previewImg.remove(); + + if (data == null || data.error) { + hideModalDialog(); + showErrorMessage(data && data.error); + return; + } + + /* index the media list by name */ + data.mediaList.forEach(function (media) { + var path = media.path; + var parts = path.split('/'); + var name = parts[parts.length - 1]; + + mediaListByName[name] = media; + }); + + /* assign details to entries */ + entries.forEach(function (entry) { + var media = mediaListByName[entry.name]; + if (media) { + entry.momentStr = media.momentStr; + entry.momentStrShort = media.momentStrShort; + entry.sizeStr = media.sizeStr; + entry.timestamp = media.timestamp; + } + }); + + /* sort the entries by timestamp */ + entries.sortKey(function (e) {return e.timestamp || e.name;}, true); + + addEntries(); + }); + } + + if (mediaType == 'picture') { + var zippedButton = $('
Zipped
'); + buttonsDiv.append(zippedButton); + + zippedButton.click(function () { + if (groupKey != null) { + doDownloadZipped(cameraId, groupKey); + } + }); + + var timelapseButton = $('
Timelapse
'); + buttonsDiv.append(timelapseButton); + + timelapseButton.click(function () { + if (groupKey != null) { + runTimelapseDialog(cameraId, groupKey, groups[groupKey]); + } + }); + } + + if (isAdmin()) { + var deleteAllButton = $('
Delete All
'); + buttonsDiv.append(deleteAllButton); + + deleteAllButton.click(function () { + if (groupKey != null) { + doDeleteAllFiles(mediaType, cameraId, groupKey, function () { + /* delete th group button */ + groupsDiv.find('div.media-dialog-group-button').each(function () { + var $this = $(this); + if (this.key == groupKey) { + $this.remove(); + } + }); + + /* delete the group itself */ + delete groups[groupKey]; + + /* show the first existing group, if any */ + var keys = Object.keys(groups); + if (keys.length) { + showGroup(keys[0]); + } + else { + hideModalDialog(); + } + }); + } + }); + } + + function updateDialogSize() { + var windowWidth = $(window).width(); + var windowHeight = $(window).height(); + + if (Object.keys(groups).length == 0) { + groupsDiv.width('auto'); + groupsDiv.height('auto'); + groupsDiv.addClass('small-screen'); + mediaListDiv.width('auto'); + mediaListDiv.height('auto'); + buttonsDiv.hide(); + + return; + } + + buttonsDiv.show(); + + if (windowWidth < 1000) { + mediaListDiv.width(parseInt(windowWidth * 0.8)); + mediaListDiv.height(parseInt(windowHeight * 0.7)); + groupsDiv.width(parseInt(windowWidth * 0.8)); + groupsDiv.height(''); + groupsDiv.addClass('small-screen'); + } + else { + mediaListDiv.width(parseInt(windowWidth * 0.7)); + mediaListDiv.height(parseInt(windowHeight * 0.7)); + groupsDiv.height(parseInt(windowHeight * 0.7)); + groupsDiv.width(''); + groupsDiv.removeClass('small-screen'); + } + } + + function onResize() { + updateDialogSize(); + updateModalDialogPosition(); + } + + $(window).resize(onResize); + + updateDialogSize(); + + showModalDialog(''); + + /* fetch the media list */ + ajax('GET', baseUri + mediaType + '/' + cameraId + '/list/', null, function (data) { + if (data == null || data.error) { + hideModalDialog(); + showErrorMessage(data && data.error); + return; + } + + /* group the media */ + data.mediaList.forEach(function (media) { + var path = media.path; + var parts = path.split('/'); + var keyParts = parts.splice(0, parts.length - 1); + var key = keyParts.join('/'); + + if (key.indexOf('/') === 0) { + key = key.substring(1); + } + + var list = (groups[key] = groups[key] || []); + + list.push({ + 'path': path, + 'group': key, + 'name': parts[parts.length - 1], + 'cameraId': cameraId + }); + }); + + updateDialogSize(); + + var keys = Object.keys(groups); + keys.sort(); + keys.reverse(); + + if (keys.length) { + keys.forEach(function (key) { + var groupButton = $('
'); + groupButton.text((key || '(ungrouped)') + ' (' + groups[key].length + ')'); + groupButton[0].key = key; + + groupButton.click(function () { + showGroup(key); + }); + + groupsDiv.append(groupButton); + }); + + /* add tooltips to larger group buttons */ + setTimeout(function () { + groupsDiv.find('div.media-dialog-group-button').each(function () { + if (this.scrollWidth > this.offsetWidth) { + this.title = this.innerHTML; + } + }); + }, 10); + } + else { + groupsDiv.html('(no media files)'); + mediaListDiv.remove(); + } + + var title; + if ($(window).width() < 1000) { + title = data.cameraName; + } + else if (mediaType === 'picture') { + title = 'Pictures taken by ' + data.cameraName; + } + else { + title = 'Movies recorded by ' + data.cameraName; + } + + runModalDialog({ + title: title, + closeButton: true, + buttons: '', + content: dialogDiv, + onShow: function () { + //dialogDiv.scrollTop(dialogDiv.prop('scrollHeight')); + if (keys.length) { + showGroup(keys[0]); + } + }, + onClose: function () { + $(window).unbind('resize', onResize); + } + }); + }); + + /* install the media list scroll event handler */ + mediaListDiv.scroll(function () { + var height = mediaListDiv.height(); + + mediaListDiv.find('img.media-list-preview').each(function () { + if (!this._src) { + return; + } + + var $this = $(this); + var entryDiv = $this.parent(); + + var top1 = entryDiv.position().top; + var top2 = top1 + entryDiv.height(); + + if ((top1 >= 0 && top1 <= height) || + (top2 >= 0 && top2 <= height)) { + + this.src = this._src; + delete this._src; + } + }); + }); +} + + + /* camera frames */ + +function addCameraFrameUi(cameraConfig) { + var pageContainer = $('div.page-container'); + + if (cameraConfig == null) { + var cameraFrameDivPlaceHolder = $('
'); + pageContainer.append(cameraFrameDivPlaceHolder); + + return; + } + + var cameraId = cameraConfig.id; + + var cameraFrameDiv = $( + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
'); + + var nameSpan = cameraFrameDiv.find('span.camera-name'); + var configureButton = cameraFrameDiv.find('div.camera-button.configure'); + var picturesButton = cameraFrameDiv.find('div.camera-button.media-pictures'); + var moviesButton = cameraFrameDiv.find('div.camera-button.media-movies'); + var fullScreenButton = cameraFrameDiv.find('div.camera-button.full-screen'); + var cameraPlaceholder = cameraFrameDiv.find('div.camera-placeholder'); + var cameraProgress = cameraFrameDiv.find('div.camera-progress'); + var cameraImg = cameraFrameDiv.find('img.camera'); + var progressImg = cameraFrameDiv.find('img.camera-progress'); + + /* no camera buttons if not admin */ + if (!isAdmin()) { + configureButton.hide(); + } + + /* no media buttons for simple mjpeg cameras */ + if (cameraConfig['proto'] == 'mjpeg') { + picturesButton.hide(); + moviesButton.hide(); + } + + cameraFrameDiv.attr('id', 'camera' + cameraId); + cameraFrameDiv[0].refreshDivider = 0; + cameraFrameDiv[0].config = cameraConfig; + nameSpan.html(cameraConfig.name); + progressImg.attr('src', staticUrl + 'img/camera-progress.gif'); + + cameraProgress.click(function () { + doFullScreenCamera(cameraId); + }); + + cameraProgress.addClass('visible'); + cameraPlaceholder.css('opacity', '0'); + + /* insert the new camera frame at the right position, + * with respect to the camera id */ + var cameraFrames = pageContainer.find('div.camera-frame'); + var cameraIds = cameraFrames.map(function () {return parseInt(this.id.substring(6));}); + cameraIds.sort(); + + var index = 0; /* find the first position that is greater than the current camera id */ + while (index < cameraIds.length && cameraIds[index] < cameraId) { + index++; + } + + if (index < cameraIds.length) { + var beforeCameraFrame = pageContainer.find('div.camera-frame#camera' + cameraIds[index]); + cameraFrameDiv.insertAfter(beforeCameraFrame); + } + else { + pageContainer.append(cameraFrameDiv); + } + + /* fade in */ + cameraFrameDiv.animate({'opacity': 1}, 100); + + /* add the button handlers */ + configureButton.click(function () { + doConfigureCamera(cameraId); + }); + + picturesButton.click(function (cameraId) { + return function () { + runMediaDialog(cameraId, 'picture'); + }; + }(cameraId)); + + moviesButton.click(function (cameraId) { + return function () { + runMediaDialog(cameraId, 'movie'); + }; + }(cameraId)); + + fullScreenButton.click(function (cameraId) { + return function () { + var url = baseUri + 'picture/' + cameraId + '/frame/'; + window.open(url, '_blank'); + }; + }(cameraId)); + + /* error and load handlers */ + cameraImg.error(function () { + this.error = true; + this.loading = 0; + + cameraImg.addClass('error').removeClass('loading'); + cameraImg.height(Math.round(cameraImg.width() * 0.75)); + cameraPlaceholder.css('opacity', 1); + cameraProgress.removeClass('visible'); + cameraFrameDiv.removeClass('motion-detected'); + }); + cameraImg.load(function () { + if (refreshDisabled[cameraId]) { + return; /* refresh temporarily disabled for updating */ + } + + this.error = false; + this.loading = 0; + + cameraImg.removeClass('error').removeClass('loading'); + cameraImg.css('height', ''); + cameraPlaceholder.css('opacity', 0); + cameraProgress.removeClass('visible'); + + /* there's no point in looking for a cookie update more often than once every second */ + var now = new Date().getTime(); + if ((!this.lastCookieTime || now - this.lastCookieTime > 1000) && (cameraFrameDiv[0].config['proto'] != 'mjpeg')) { + if (getCookie('motion_detected_' + cameraId) == 'true') { + cameraFrameDiv.addClass('motion-detected'); + } + else { + cameraFrameDiv.removeClass('motion-detected'); + } + + this.lastCookieTime = now; + } + + if (fullScreenCameraId) { + /* update the modal dialog position when image is loaded */ + updateModalDialogPosition(); + } + }); + + cameraImg.addClass('loading'); + cameraImg.height(Math.round(cameraImg.width() * 0.75)); +} + +function remCameraFrameUi(cameraId) { + var pageContainer = $('div.page-container'); + var cameraFrameDiv = pageContainer.find('div.camera-frame#camera' + cameraId); + cameraFrameDiv.animate({'opacity': 0}, 100, function () { + cameraFrameDiv.remove(); + }); +} + +function recreateCameraFrames(cameras) { + var pageContainer = $('div.page-container'); + + function updateCameras(cameras) { + cameras = cameras.filter(function (camera) {return camera.enabled;}); + var i, camera; + + /* remove everything on the page */ + pageContainer.children().remove(); + + /* add camera frames */ + for (i = 0; i < cameras.length; i++) { + camera = cameras[i]; + addCameraFrameUi(camera); + } + + if ($('#cameraSelect').find('option').length < 2 && isAdmin() && $('#motionEyeSwitch')[0].checked) { + /* invite the user to add a camera */ + var addCameraLink = $(''); + pageContainer.append(addCameraLink); + } + } + + if (cameras != null) { + updateCameras(cameras); + } + else { + ajax('GET', baseUri + 'config/list/', null, function (data) { + if (data == null || data.error) { + showErrorMessage(data && data.error); + return; + } + + updateCameras(data.cameras); + }); + } + + /* update settings panel */ + var cameraId = $('#cameraSelect').val(); + if (cameraId && cameraId != 'add') { + openSettings(cameraId); + } +} + + +function doConfigureCamera(cameraId) { + if (inProgress) { + return; + } + + hideApply(); + pushConfigs = {}; + pushConfigReboot = false; + + openSettings(cameraId); +} + +function doFullScreenCamera(cameraId) { + if (inProgress || refreshDisabled[cameraId]) { + return; + } + + if (fullScreenCameraId != null) { + return; /* a camera is already in full screen */ + } + + fullScreenCameraId = -1; /* avoids successive fast toggles of fullscreen */ + + var cameraFrameDiv = $('#camera' + cameraId); + var cameraName = cameraFrameDiv.find('span.camera-name').text(); + var frameImg = cameraFrameDiv.find('img.camera'); + var aspectRatio = frameImg.width() / frameImg.height(); + var windowWidth = $(window).width(); + var windowHeight = $(window).height(); + var windowAspectRatio = windowWidth / windowHeight; + var frameIndex = cameraFrameDiv.index(); + var pageContainer = $('div.page-container'); + + if (frameImg.hasClass('error')) { + return; /* no full screen for erroneous cameras */ + } + + var width; + if (windowAspectRatio > aspectRatio) { + width = aspectRatio * Math.round(0.8 * windowHeight); + } + else { + width = Math.round(0.9 * windowWidth); + } + + cameraFrameDiv.find('div.camera-progress').addClass('visible'); + + var cameraImg = cameraFrameDiv.find('img.camera'); + cameraImg.load(function showFullScreenCamera() { + cameraFrameDiv.css('width', width); + fullScreenCameraId = cameraId; + + runModalDialog({ + title: cameraName, + closeButton: true, + content: cameraFrameDiv, + onShow: function () { + cameraImg.unbind('load', showFullScreenCamera); + }, + onClose: function () { + fullScreenCameraId = null; + cameraFrameDiv.css('width', ''); + var nextFrame = pageContainer.children('div:eq(' + frameIndex + ')'); + if (nextFrame.length) { + nextFrame.before(cameraFrameDiv); + } + else { + pageContainer.append(cameraFrameDiv); + } + } + }); + }); + + if (cameraFrameDiv[0].config['proto'] == 'mjpeg') { + /* manually trigger the load event on simple mjpeg cameras */ + cameraImg.load(); + } +} + +function refreshCameraFrames() { + function refreshCameraFrame(cameraId, img, serverSideResize) { + if (refreshDisabled[cameraId]) { + /* camera refreshing disabled, retry later */ + + return; + } + + if (img.loading) { + img.loading++; /* increases each time the camera would refresh but is still loading */ + + if (img.loading > 2 * 1000 / refreshInterval) { /* limits the retries to one every two seconds */ + img.loading = 0; + } + else { + return; /* wait for the previous frame to finish loading */ + } + } + + var timestamp = new Date().getTime(); + var uri = baseUri + 'picture/' + cameraId + '/current/?_=' + timestamp; + if (serverSideResize) { + uri += '&width=' + img.width; + } + + uri = addAuthParams('GET', uri); + + img.src = uri; + img.loading = 1; + } + + var cameraFrames; + if (fullScreenCameraId != null && fullScreenCameraId >= 0) { + cameraFrames = $('#camera' + fullScreenCameraId); + } + else { + cameraFrames = $('div.page-container').find('div.camera-frame'); + } + + cameraFrames.each(function () { + if (!this.img) { + this.img = $(this).find('img.camera')[0]; + if (this.config['proto'] == 'mjpeg') { + var url = this.config['url'].replace('127.0.0.1', window.location.host.split(':')[0]); + url += (url.indexOf('?') > 0 ? '&' : '?') + '_=' + new Date().getTime(); + this.img.src = url; + } + } + + if (this.config['proto'] == 'mjpeg') { + return; /* no manual refresh for simple mjpeg cameras */ + } + + /* at a refresh interval of 50ms, the refresh rate is limited to 20 fps */ + var count = 1000 / (refreshInterval * this.config['streaming_framerate']); + var serverSideResize = this.config['streaming_server_resize']; + + if (count <= 2) { + /* skipping frames (showing the same frame twice) at this rate won't be visible, + * while the effective framerate will be as close as possible to the motion's one */ + count -= 1; + } + + if (this.img.error) { + /* in case of error, decrease the refresh rate to 1 fps */ + count = 1000 / refreshInterval; + } + + if (this.refreshDivider < count) { + this.refreshDivider++; + } + else { + var cameraId = this.id.substring(6); + refreshCameraFrame(cameraId, this.img, serverSideResize); + + this.refreshDivider = 0; + } + }); + + setTimeout(refreshCameraFrames, refreshInterval); +} + +function checkCameraErrors() { + /* properly triggers the onerror event on the cameras whose imgs were not successfully loaded, + * but the onerror event hasn't been triggered, for some reason (seems to happen in Chrome) */ + var cameraFrames = $('div.page-container').find('img.camera'); + + cameraFrames.each(function () { + if (this.complete === true && this.naturalWidth === 0 && !this.error && this.src) { + $(this).error(); + } + }); + + setTimeout(checkCameraErrors, 500); +} + + + /* startup function */ + +$(document).ready(function () { + /* detect base uri */ + if (frame) { + baseUri = qualifyUri('../../../'); + + } + else { + baseUri = splitUrl(qualifyUri('')).baseUrl; + + /* restore the username from cookie */ + window.username = getCookie('username'); + } + + /* open/close settings */ + $('div.settings-button').click(function () { + if (isSettingsOpen()) { + closeSettings(); + } + else { + openSettings(); + } + }); + + /* software update button */ + $('div#updateButton').click(doUpdate); + + /* backup/restore */ + $('div#backupButton').click(doBackup); + $('div#restoreButton').click(doRestore); + + /* prevent scroll events on settings div from propagating TODO this does not actually work */ + $('div.settings').mousewheel(function (e, d) { + var t = $(this); + if (d > 0 && t.scrollTop() === 0) { + e.preventDefault(); + } + else if (d < 0 && (t.scrollTop() === t.get(0).scrollHeight - t.innerHeight())) { + e.preventDefault(); + } + }); + + initUI(); + beginProgress(); + + ajax('GET', baseUri + 'login/', null, function () { + if (!frame) { + fetchCurrentConfig(endProgress); + } + }); + + refreshCameraFrames(); + checkCameraErrors(); +}); + diff --git a/motioneye/static/js/ui.js b/motioneye/static/js/ui.js new file mode 100644 index 0000000..7dfa9bb --- /dev/null +++ b/motioneye/static/js/ui.js @@ -0,0 +1,1100 @@ + +var _modalDialogContexts = []; + + + /* UI widgets */ + +function makeCheckBox($input) { + $input.each(function () { + var $this = $(this); + + var mainDiv = $('
'); + var buttonDiv = $('
'); + var text = $(''); + + function setOn() { + text.html('ON'); + mainDiv.addClass('on'); + } + + function setOff() { + text.html('OFF'); + mainDiv.removeClass('on'); + } + + buttonDiv.append(text); + mainDiv.append(buttonDiv); + + /* transfer the CSS classes */ + mainDiv[0].className += ' ' + $this[0].className; + + /* add the element */ + $this.after(mainDiv); + + function update() { + if ($this[0].checked) { + setOn(); + } + else { + setOff(); + } + } + + /* add event handers */ + $this.change(update).change(); + + mainDiv.click(function () { + $this[0].checked = !$this[0].checked; + $this.change(); + }); + + /* make the element focusable */ + mainDiv[0].tabIndex = 0; + + /* handle the key events */ + mainDiv.keydown(function (e) { + if (e.which === 13 || e.which === 32) { + $this[0].checked = !$this[0].checked; + $this.change(); + + return false; + } + }); + + this.update = update; + }); +} + +function makeSlider($input, minVal, maxVal, snapMode, ticks, ticksNumber, decimals, unit) { + unit = unit || ''; + + $input.each(function () { + var $this = $(this); + var slider = $('
'); + + var labels = $('
'); + slider.append(labels); + + var bar = $('
'); + slider.append(bar); + + bar.append('
'); + + var cursor = $('
'); + bar.append(cursor); + + var cursorLabel = $('
'); + cursor.append(cursorLabel); + + function bestPos(pos) { + if (pos < 0) { + pos = 0; + } + if (pos > 100) { + pos = 100; + } + + if (snapMode > 0) { + var minDif = Infinity; + var bestPos = null; + for (var i = 0; i < ticks.length; i++) { + var tick = ticks[i]; + var p = valToPos(tick.value); + var dif = Math.abs(p - pos); + if ((dif < minDif) && (snapMode == 1 || dif < 5)) { + minDif = dif; + bestPos = p; + } + } + + if (bestPos != null) { + pos = bestPos; + } + } + + return pos; + } + + function getPos() { + return parseInt(cursor.position().left * 100 / bar.width()); + } + + function valToPos(val) { + return (val - minVal) * 100 / (maxVal - minVal); + } + + function posToVal(pos) { + return minVal + pos * (maxVal - minVal) / 100; + } + + function sliderChange(val) { + $this.val(val.toFixed(decimals)); + cursorLabel.html('' + val.toFixed(decimals) + unit); + } + + function bodyMouseMove(e) { + if (bar[0]._mouseDown) { + var offset = bar.offset(); + var pos = e.pageX - offset.left - 5; + pos = pos / slider.width() * 100; + pos = bestPos(pos); + var val = posToVal(pos); + + cursor.css('left', pos + '%'); + sliderChange(val); + } + } + + function bodyMouseUp(e) { + bar[0]._mouseDown = false; + + $('body').unbind('mousemove', bodyMouseMove); + $('body').unbind('mouseup', bodyMouseUp); + + cursorLabel.css('display', 'none'); + + $this.change(); + } + + bar.mousedown(function (e) { + if (e.which > 1) { + return; + } + + this._mouseDown = true; + bodyMouseMove(e); + + $('body').mousemove(bodyMouseMove); + $('body').mouseup(bodyMouseUp); + + slider.focus(); + cursorLabel.css('display', 'inline-block'); + + return false; + }); + + /* ticks */ + var autoTicks = (ticks == null); + + function makeTicks() { + if (ticksNumber == null) { + ticksNumber = 11; + } + + labels.html(''); + + if (autoTicks) { + ticks = []; + var i; + for (i = 0; i < ticksNumber; i++) { + var val = minVal + i * (maxVal - minVal) / (ticksNumber - 1); + var valStr; + if (Math.round(val) == val) { + valStr = '' + val; + } + else { + valStr = val.toFixed(decimals); + } + ticks.push({value: val, label: valStr + unit}); + } + } + + for (i = 0; i < ticks.length; i++) { + var tick = ticks[i]; + var pos = valToPos(tick.value); + var span = $('' + tick.label + ''); + + labels.append(span); + span.css('left', (pos - 10) + '%'); + } + + return ticks; + } + + makeTicks(); + + function input2slider() { + var value = parseFloat($this.val()); + if (isNaN(value)) { + value = minVal; + } + + var pos = valToPos(value); + pos = bestPos(pos); + cursor.css('left', pos + '%'); + cursorLabel.html($this.val() + unit); + } + + /* transfer the CSS classes */ + slider.addClass($this.attr('class')); + + /* handle input events */ + $this.change(input2slider).change(); + + /* add the slider to the parent of the input */ + $this.after(slider); + + /* make the slider focusable */ + slider.attr('tabIndex', 0); + + /* handle key events */ + slider.keydown(function (e) { + switch (e.which) { + case 37: /* left */ + if (snapMode == 1) { /* strict snapping */ + // TODO implement me + } + else { + var step = (maxVal - minVal) / 200; + var val = Math.max(minVal, parseFloat($this.val()) - step); + if (decimals == 0) { + val = Math.floor(val); + } + + var origSnapMode = snapMode; + snapMode = 0; + $this.val(val).change(); + snapMode = origSnapMode; + } + + break; + + case 39: /* right */ + if (snapMode == 1) { /* strict snapping */ + // TODO implement me + } + else { + var step = (maxVal - minVal) / 200; + var val = Math.min(maxVal, parseFloat($this.val()) + step); + if (decimals == 0) { + val = Math.ceil(val); + } + + var origSnapMode = snapMode; + snapMode = 0; + $this.val(val).change(); + snapMode = origSnapMode; + } + + break; + } + }); + + this.update = input2slider; + + slider[0].setMinVal = function (mv) { + minVal = mv; + + makeTicks(); + }; + + slider[0].setMaxVal = function (mv) { + maxVal = mv; + + makeTicks(); + + input2slider(); + }; + }); +} + +function makeProgressBar($div) { + $div.each(function () { + var $this = $(this); + + $this.addClass('progress-bar-container'); + var fillDiv = $('
'); + var textSpan = $(''); + + $this.append(fillDiv); + $this.append(textSpan); + + this.setProgress = function (progress) { + $this.progress = progress; + fillDiv.width(progress + '%'); + }; + + this.setText = function (text) { + textSpan.html(text); + }; + }); +} + + + /* validators */ + +function makeTextValidator($input, required) { + if (required == null) { + required = true; + } + + $input.each(function () { + var $this = $(this); + + function isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } + + if (strVal.length === 0 && required) { + return false; + } + + return true; + } + + var msg = 'this field is required'; + + function validate() { + var strVal = $this.val(); + if (isValid(strVal)) { + $this.attr('title', ''); + $this.removeClass('error'); + $this[0].invalid = false; + } + else { + $this.attr('title', msg); + $this.addClass('error'); + $this[0].invalid = true; + } + } + + $this.keyup(validate); + $this.blur(validate); + $this.change(validate).change(); + + $this.addClass('validator'); + $this.addClass('text-validator'); + $this.each(function () { + var oldValidate = this.validate; + this.validate = function () { + if (oldValidate) { + oldValidate.call(this); + } + validate(); + } + }); + }); +} + +function makeComboValidator($select, required) { + if (required == null) { + required = true; + } + + $select.each(function () { + $this = $(this); + + function isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } + + if (strVal.length === 0 && required) { + return false; + } + + return true; + } + + var msg = 'this field is required'; + + function validate() { + var strVal = $this.val() || ''; + if (isValid(strVal)) { + $this.attr('title', ''); + $this.removeClass('error'); + $this[0].invalid = false; + } + else { + $this.attr('title', msg); + $this.addClass('error'); + $this[0].invalid = true; + } + } + + $this.keyup(validate); + $this.blur(validate); + $this.change(validate).change(); + + $this.addClass('validator'); + $this.addClass('combo-validator'); + $this.each(function () { + var oldValidate = this.validate; + this.validate = function () { + if (oldValidate) { + oldValidate.call(this); + } + validate(); + } + }); + }); +} + +function makeNumberValidator($input, minVal, maxVal, floating, sign, required) { + if (minVal == null) { + minVal = -Infinity; + } + if (maxVal == null) { + maxVal = Infinity; + } + if (floating == null) { + floating = false; + } + if (sign == null) { + sign = false; + } + if (required == null) { + required = true; + } + + $input.each(function () { + var $this = $(this); + + function isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } + + if (strVal.length === 0 && !required) { + return true; + } + + var numVal = parseInt(strVal); + if ('' + numVal != strVal) { + return false; + } + + if (numVal < minVal || numVal > maxVal) { + return false; + } + + if (!sign && numVal < 0) { + return false; + } + + return true; + } + + var msg = ''; + if (!sign) { + msg = 'enter a positive'; + } + else { + msg = 'enter a'; + } + if (floating) { + msg += ' number'; + } + else { + msg += ' integer number'; + } + if (isFinite(minVal)) { + if (isFinite(maxVal)) { + msg += ' between ' + minVal + ' and ' + maxVal; + } + else { + msg += ' greater than ' + minVal; + } + } + else { + if (isFinite(maxVal)) { + msg += ' smaller than ' + maxVal; + } + } + + function validate() { + var strVal = $this.val(); + if (isValid(strVal)) { + $this.attr('title', ''); + $this.removeClass('error'); + $this[0].invalid = false; + } + else { + $this.attr('title', msg); + $this.addClass('error'); + $this[0].invalid = true; + } + } + + $this.keyup(validate); + $this.blur(validate); + $this.change(validate).change(); + + $this.addClass('validator'); + $this.addClass('number-validator'); + $this.each(function () { + var oldValidate = this.validate; + this.validate = function () { + if (oldValidate) { + oldValidate.call(this); + } + validate(); + } + }); + }); + + makeStrippedInput($input); +} + +function makeTimeValidator($input) { + $input.each(function () { + var $this = $(this); + + function isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } + + return strVal.match(new RegExp('^[0-2][0-9]:[0-5][0-9]$')) != null; + } + + var msg = 'enter a valid time in the following format: HH:MM'; + + function validate() { + var strVal = $this.val(); + if (isValid(strVal)) { + $this.attr('title', ''); + $this.removeClass('error'); + $this[0].invalid = false; + } + else { + $this.attr('title', msg); + $this.addClass('error'); + $this[0].invalid = true; + } + } + + $this.keyup(validate); + $this.blur(validate); + $this.change(validate).change(); + $this.timepicker({ + closeOnWindowScroll: true, + selectOnBlur: true, + timeFormat: 'H:i', + }); + + $this.addClass('validator'); + $this.addClass('time-validator'); + $this.each(function () { + var oldValidate = this.validate; + this.validate = function () { + if (oldValidate) { + oldValidate.call(this); + } + validate(); + } + }); + }); + + makeStrippedInput($input); +} + +function makeUrlValidator($input) { + $input.each(function () { + var $this = $(this); + + function isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } + + return strVal.match(new RegExp('^([a-zA-Z]+)://([\\w\-.]+)(:\\d+)?(/.*)?$')) != null; + } + + var msg = 'enter a valid URL (e.g. http://example.com:8080/cams/)'; + + function validate() { + var strVal = $this.val(); + if (isValid(strVal)) { + $this.attr('title', ''); + $this.removeClass('error'); + $this[0].invalid = false; + } + else { + $this.attr('title', msg); + $this.addClass('error'); + $this[0].invalid = true; + } + } + + $this.keyup(validate); + $this.blur(validate); + $this.change(validate).change(); + + $this.addClass('validator'); + $this.addClass('url-validator'); + $this.each(function () { + var oldValidate = this.validate; + this.validate = function () { + if (oldValidate) { + oldValidate.call(this); + } + validate(); + } + }); + }); +} + +function makeFileValidator($input, required) { + if (required == null) { + required = true; + } + + $input.each(function () { + var $this = $(this); + + function isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } + + if (strVal.length === 0 && required) { + return false; + } + + return true; + } + + var msg = 'this field is required'; + + function validate() { + var strVal = $this.val(); + if (isValid(strVal)) { + $this.attr('title', ''); + $this.removeClass('error'); + $this[0].invalid = false; + } + else { + $this.attr('title', msg); + $this.addClass('error'); + $this[0].invalid = true; + } + } + + $this.keyup(validate); + $this.blur(validate); + $this.change(validate).change(); + + $this.addClass('validator'); + $this.addClass('file-validator'); + $this.each(function () { + var oldValidate = this.validate; + this.validate = function () { + if (oldValidate) { + oldValidate.call(this); + } + validate(); + } + }); + }); +} +function makeCustomValidator($input, isValidFunc) { + $input.each(function () { + var $this = $(this); + + function isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } + + return isValidFunc(strVal); + } + + function validate() { + var strVal = $this.val(); + var valid = isValid(strVal); + if (valid == true) { + $this.attr('title', ''); + $this.removeClass('error'); + $this[0].invalid = false; + } + else { + $this.attr('title', valid || 'enter a valid value'); + $this.addClass('error'); + $this[0].invalid = true; + } + } + + $this.keyup(validate); + $this.blur(validate); + $this.change(validate).change(); + + $this.addClass('validator'); + $this.addClass('custom-validator'); + $this.each(function () { + var oldValidate = this.validate; + this.validate = function () { + if (oldValidate) { + oldValidate.call(this); + } + validate(); + } + }); + }); +} + + + /* other input value processors */ + +function makeStrippedInput($input) { + $input.change(function () { + this.value = $.trim(this.value); + }); +} + +function makeCharReplacer($input, oldChars, newStr) { + $input.change(function () { + this.value = this.value.replace(new RegExp('[' + oldChars + ']', 'g'), newStr); + }); +} + + + /* modal dialog */ + +function showModalDialog(content, onClose, onShow, stack) { + var glass = $('div.modal-glass'); + var container = $('div.modal-container'); + + if (container.is(':animated')) { + return setTimeout(function () { + showModalDialog(content, onClose, onShow, stack); + }, 100); + } + + if (container.is(':visible') && stack) { + /* the modal dialog is already visible, + * we just replace the content */ + + var children = container.children(':visible'); + _modalDialogContexts.push({ + children: children, + onClose: container[0]._onClose, + }); + + children.css('display', 'none'); + updateModalDialogPosition(); + + container[0]._onClose = onClose; /* set the new onClose handler */ + container.append(content); + updateModalDialogPosition(); + + if (onShow) { + onShow(); + } + + return; + } + + glass.css('display', 'block'); + glass.animate({'opacity': '0.7'}, 200); + + container[0]._onClose = onClose; /* remember the onClose handler */ + container.html(content); + + container.css('display', 'block'); + updateModalDialogPosition(); + container.animate({'opacity': '1'}, 200); + + if (onShow) { + onShow(); + } +} + +function hideModalDialog() { + var glass = $('div.modal-glass'); + var container = $('div.modal-container'); + + if (container.is(':animated')) { + return setTimeout(function () { + hideModalDialog(); + }, 100); + } + + if (_modalDialogContexts.length) { + if (container[0]._onClose) { + container[0]._onClose(); + } + + container.children(':visible').remove(); + + var context = _modalDialogContexts.pop(); + context.children.css('display', ''); + container[0]._onClose = context.onClose; + updateModalDialogPosition(); + + return; + } + + glass.animate({'opacity': '0'}, 200, function () { + glass.css('display', 'none'); + }); + + container.animate({'opacity': '0'}, 200, function () { + container.css('display', 'none'); + container.html(''); + }); + + /* run the onClose handler, if supplied */ + if (container[0]._onClose) { + container[0]._onClose(); + } +} + +function updateModalDialogPosition() { + var container = $('div.modal-container'); + if (!container.is(':visible')) { + return; + } + + var windowWidth = $(window).width(); + var windowHeight = $(window).height(); + var modalWidth, modalHeight, i; + + /* repeat the operation multiple times, the size might change */ + for (i = 0; i < 3; i++) { + modalWidth = container.outerWidth(); + modalHeight = container.outerHeight(); + + container.css('left', Math.floor((windowWidth - modalWidth) / 2)); + container.css('top', Math.floor((windowHeight - modalHeight) / 2)); + } +} + +function makeModalDialogButtons(buttonsInfo) { + /* buttonsInfo is an array of: + * * caption: String + * * isDefault: Boolean + * * click: Function + */ + + var buttonsContainer = $(''); + var tr = buttonsContainer.find('tr'); + + buttonsInfo.forEach(function (info) { + var buttonDiv = $('
'); + + buttonDiv.attr('tabIndex', '0'); /* make button focusable */ + buttonDiv.html(info.caption); + + if (info.isDefault) { + buttonDiv.addClass('default'); + } + + if (info.click) { + var oldClick = info.click; + info.click = function () { + if (oldClick() == false) { + return false; + } + + hideModalDialog(); + + return false; + }; + } + else { + info.click = hideModalDialog; /* every button closes the dialog */ + } + + buttonDiv.click(info.click); + + var td = $(''); + td.append(buttonDiv); + tr.append(td); + }); + + /* limit the size of the buttons container */ + buttonsContainer.css('max-width', (buttonsInfo.length * 10) + 'em'); + + return buttonsContainer; +} + +function makeModalDialogTitleBar(options) { + /* available options: + * * title: String + * * closeButton: Boolean + */ + + var titleBar = $(''); + + var titleSpan = $(''); + titleSpan.html(options.title || ''); + if (options.closeButton) { + titleSpan.css('margin', '0px 1.5em'); + } + + titleBar.append(titleSpan); + + if (options.closeButton) { + var closeButton = $(''); + closeButton.click(hideModalDialog); + titleBar.append(closeButton); + } + + return titleBar; +} + +function runModalDialog(options) { + /* available options: + * * title: String + * * closeButton: Boolean + * * content: any + * * buttons: 'ok'|'yesno'|'okcancel'|Array + * * onYes: Function + * * onNo: Function + * * onOk: Function + * * onCancel: Function + * * onClose: Function + * * onShow: Function + * * stack: Boolean + * * noKeys: Boolean + */ + + var content = $('
'); + var titleBar = null; + var buttonsDiv = null; + var defaultClick = null; + var cancelClick = null; + + /* add title bar */ + if (options.title) { + titleBar = makeModalDialogTitleBar({title: options.title, closeButton: options.closeButton}); + content.append(titleBar); + } + + /* add supplied content */ + if (options.content) { + var contentWrapper = $('
'); + contentWrapper.append(options.content); + content.append(contentWrapper); + } + + /* add buttons */ + if (options.buttons === 'yesno') { + options.buttons = [ + {caption: 'No', click: options.onNo}, + {caption: 'Yes', isDefault: true, click: options.onYes} + ]; + } + if (options.buttons === 'yesnocancel') { + options.buttons = [ + {caption: 'Cancel', isCancel: true, click: options.onCancel}, + {caption: 'No', click: options.onNo}, + {caption: 'Yes', isDefault: true, click: options.onYes} + ]; + } + else if (options.buttons === 'okcancel') { + options.buttons = [ + {caption: 'Cancel', isCancel:true, click: options.onCancel}, + {caption: 'OK', isDefault: true, click: options.onOk} + ]; + } + else if (options.buttons === 'ok') { + options.buttons = [ + {caption: 'OK', isDefault: true, click: options.onOk} + ]; + } + + if (options.buttons) { + buttonsDiv = makeModalDialogButtons(options.buttons); + content.append(buttonsDiv); + + options.buttons.forEach(function (info) { + if (info.isDefault) { + defaultClick = info.click; + } + else if (info.isCancel) { + cancelClick = info.click; + } + }); + } + + /* add some margins */ + if ((buttonsDiv || options.content) && titleBar) { + titleBar.css('margin-bottom', '5px'); + } + + if (buttonsDiv && options.content) { + buttonsDiv.css('margin-top', '5px'); + } + + var handleKeyUp = !options.noKeys && function (e) { + if (!content.is(':visible')) { + return; + } + + switch (e.which) { + case 13: + if (defaultClick && defaultClick() == false) { + return; + } + + hideModalDialog(); + + break; + + case 27: + if (cancelClick && cancelClick() == false) { + return; + } + + hideModalDialog(); + + break; + } + }; + + var onClose = function () { + if (options.onClose) { + options.onClose(); + } + + /* unbind html handlers */ + + $('html').unbind('keyup', handleKeyUp); + }; + + /* bind key handlers */ + $('html').bind('keyup', handleKeyUp); + + /* and finally, show the dialog */ + + showModalDialog(content, onClose, options.onShow, options.stack); + + /* focus the default button if nothing else is focused */ + if (content.find('*:focus').length === 0) { + content.find('div.button.default').focus(); + } +} + + + /* popup message */ + +function showPopupMessage(message, type) { + var container = $('div.popup-message-container'); + var content = $(''); + + if (window._popupMessageTimeout) { + clearTimeout(window._popupMessageTimeout); + } + + content.html(message); + content.addClass(type); + container.html(content); + + var windowWidth = $(window).width(); + var messageWidth = container.width(); + + container.css('display', 'block'); + container.css('left', (windowWidth - messageWidth) / 2); + + container.animate({'opacity': '1'}, 200); + + window._popupMessageTimeout = setTimeout(function () { + window._popupMessageTimeout = null; + container.animate({'opacity': '0'}, 200, function () { + container.css('display', 'none'); + }); + }, 5000); +} diff --git a/motioneye/static/js/version.js b/motioneye/static/js/version.js new file mode 100644 index 0000000..7d43298 --- /dev/null +++ b/motioneye/static/js/version.js @@ -0,0 +1,6 @@ + +$(window).load(function () { + if (window.parent && window.parent.postMessage) { + window.parent.postMessage({'hostname': hostname, 'version': version, 'url': window.location.href.replace('version/', '')}, '*'); + } +}); diff --git a/motioneye/template.py b/motioneye/template.py new file mode 100644 index 0000000..49ce299 --- /dev/null +++ b/motioneye/template.py @@ -0,0 +1,66 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from jinja2 import Environment, FileSystemLoader + +import settings +import utils + + +_jinja_env = None + + +def _init_jinja(): + global _jinja_env + + _jinja_env = Environment( + loader=FileSystemLoader(settings.TEMPLATE_PATH), + trim_blocks=False) + + # globals + _jinja_env.globals['settings'] = settings + + # filters + _jinja_env.filters['pretty_date_time'] = utils.pretty_date_time + _jinja_env.filters['pretty_date'] = utils.pretty_date + _jinja_env.filters['pretty_time'] = utils.pretty_time + _jinja_env.filters['pretty_duration'] = utils.pretty_duration + + +def add_template_path(path): + global _jinja_env + if _jinja_env is None: + _init_jinja() + + _jinja_env.loader.searchpath.append(path) + + +def add_context(name, value): + global _jinja_env + if _jinja_env is None: + _init_jinja() + + _jinja_env.globals[name] = value + + +def render(template_name, **context): + global _jinja_env + if _jinja_env is None: + _init_jinja() + + template = _jinja_env.get_template(template_name) + return template.render(**context) diff --git a/motioneye/templates/base.html b/motioneye/templates/base.html new file mode 100644 index 0000000..6679b8d --- /dev/null +++ b/motioneye/templates/base.html @@ -0,0 +1,32 @@ + + + + {% block meta %} + + + + + + {% endblock %} + {% block title %}{% endblock %} + {% block style %} + + + + {% endblock %} + {% block script %} + + + + + + + {% endblock %} + + + + {% block body %}{% endblock %} + + diff --git a/motioneye/templates/main.html b/motioneye/templates/main.html new file mode 100644 index 0000000..199e08f --- /dev/null +++ b/motioneye/templates/main.html @@ -0,0 +1,826 @@ +{% extends "base.html" %} + +{% macro config_item(config) -%} + + {% if config['type'] == 'separator' %} +
+ {% else %} + {{config['label']}} + + {% if config['type'] == 'str' %} + + {% elif config['type'] == 'pwd' %} + + {% elif config['type'] == 'number' %} + + {% elif config['type'] == 'range' %} + + {% elif config['type'] == 'bool' %} + + {% elif config['type'] == 'choices' %} + + {% elif config['type'] == 'html' %} +
+ {% endif %} + + {% if config.get('description') %}?{% endif %} + {% endif %} + +{%- endmacro %} + +{% block title %}{% if title %}{{title}}{% else %}{{hostname}}{% endif %}{% endblock %} + +{% block style %} + {{super()}} + + + {% if frame %} + + {% endif %} +{% endblock %} + +{% block script %} + {{super()}} + + + {% if frame %} + + {% endif %} + +{% endblock %} + +{% block body %} + {% if not frame %} +
+
+
+
+
+ +
+
Apply
+ {% if hostname %}
{{hostname}}
{% endif %} +
+
+ +
+
+ {% endif %} + {% if not frame %} +
+
+
+
+ + ? + General Settings + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {% for config in main_sections.get('general', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} + + + + + + + + {% if enable_update %} + + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + +
Show Advanced Settings?
Administrator Username?
Administrator Password?
Surveillance Username?
Surveillance Password?
Current Version{{version}}
Software Update
Check
?
Power
Shut Down
?
Reboot
?
Configuration
Backup
?
Restore
?
+ + {% for section in main_sections.values() %} + {% if section.get('label') and section.get('configs') %} +
+ {% if section.get('onoff') %}{% endif %} + {% if section.get('description') %}?{% endif %} + {{section['label']}} + +
+ + {% for config in section['configs'] %} + {{config_item(config)}} + {% endfor %} +
+ {% endif %} + {% endfor %} + + +
+ + +
+ + ? + Video Device + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for config in camera_sections.get('device', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Camera Name?
Camera Device
Camera Type
Light Switch Detection?
Automatic Brightness?
Brightness?
Contrast?
Saturation?
Hue?
Video Resolution + + ?
Video Rotation + + ?
Frame Rate?
Extra Motion Options?
+ +
+ ? + File Storage + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for config in camera_sections.get('storage', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Storage Device + + ?
Network Server?
Share Name?
Share Username?
Share Password?
Root Directory?
Disk Usage +
+
?
+ +
+ + ? + Text Overlay + +
+ + + + + + + + + + + + + + + + + + + + + + {% for config in camera_sections.get('text-overlay', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Left Text + + ?
?
Right Text + + ?
?
+ +
+ + ? + Video Streaming + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {% if not old_motion %} + + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + {% for config in camera_sections.get('streaming', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Streaming Frame Rate?
Streaming Quality?
Streaming Image Resizing?
Streaming Resolution?
Streaming Port?
Authentication Mode + + ?
Motion Optimization?
Snapshot URL?
Streaming URL?
Embed URL?
+ +
+ + ? + Still Images + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for config in camera_sections.get('still-images', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Image File Name?
Image Quality?
Capture Mode + + ?
Snapshot Intervalseconds?
Preserve Pictures + + ?
Pictures Lifetimedays?
+ +
+ + ? + Motion Detection + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for config in camera_sections.get('motion-detection', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Show Frame Changes?
Frame Change Threshold?
Auto Noise Detection?
Noise Level?
Motion Gapseconds?
Captured Beforeframes?
Captured Afterframes?
Minimum Motion Framesframes?
+ +
+ + ? + Motion Movies + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {% for config in camera_sections.get('motion-movies', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Movie File Name?
Movie Quality?
Maximum Movie Lengthseconds?
Preserve Movies + + ?
Movies Lifetimedays?
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for config in camera_sections.get('notifications', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Email Notifications?
Email Addresses?
SMTP Server?
SMTP Port?
SMTP Account?
SMTP Password?
Use TLS?
Attached Pictures Time Spanseconds?
Web Hook Notifications?
Web Hook URL?
HTTP Method + + ?
Run A Command?
Command?
+ +
+ + ? + Working Schedule + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for config in camera_sections.get('working-schedule', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Monday + + from + to + ?
Tuesday + + from + to + ?
Wednesday + + from + to + ?
Thursday + + from + to + ?
Friday + + from + to + ?
Saturday + + from + to + ?
Sunday + + from + to + ?
Detect Motion + + ?
+ + {% for section in camera_sections.values() %} + {% if section.get('label') and section.get('configs') %} +
+ {% if section.get('onoff') %}{% endif %} + {% if section.get('description') %}?{% endif %} + {{section['label']}} + +
+ + {% for config in section['configs'] %} + {{config_item(config)}} + {% endfor %} +
+ {% endif %} + {% endfor %} + +
+
+
+ +
+ +
+ {% else %} +
+ +
+
+
+
+ {% endif %} + + + +{% endblock %} diff --git a/motioneye/templates/version.html b/motioneye/templates/version.html new file mode 100644 index 0000000..3c7a8f8 --- /dev/null +++ b/motioneye/templates/version.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block script %} + {{super()}} + + +{% endblock %} + +{% block body %} +hostname = "{{hostname}}"
+version = "{{version}}" +{% endblock %} diff --git a/motioneye/thumbnailer.py b/motioneye/thumbnailer.py new file mode 100644 index 0000000..c702863 --- /dev/null +++ b/motioneye/thumbnailer.py @@ -0,0 +1,86 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import logging +import multiprocessing +import os +import signal +import tornado + +import cleanup +import mediafiles +import settings + + +_process = None + + +def start(): + # schedule the first call a bit later to improve performance at startup + ioloop = tornado.ioloop.IOLoop.instance() + ioloop.add_timeout(datetime.timedelta(seconds=30), _run_process) + + +def stop(): + global _process + + if not running(): + _process = None + return + + if _process.is_alive(): + _process.join(timeout=10) + + if _process.is_alive(): + logging.error('thumbnailer process did not finish in time, killing it...') + os.kill(_process.pid, signal.SIGKILL) + + _process = None + + +def running(): + return _process is not None and _process.is_alive() + + +def _run_process(): + global _process + + # schedule the next call + ioloop = tornado.ioloop.IOLoop.instance() + ioloop.add_timeout(datetime.timedelta(seconds=settings.THUMBNAILER_INTERVAL), _run_process) + + if not running() and not cleanup.running(): # check that the previous process has finished and that cleanup is not running + logging.debug('running thumbnailer process...') + + _process = multiprocessing.Process(target=_do_next_movie_thumbail) + _process.start() + + +def _do_next_movie_thumbail(): + # this will be executed in a separate subprocess + + # ignore the terminate and interrupt signals in this subprocess + signal.signal(signal.SIGINT, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + try: + mediafiles.make_next_movie_preview() + + except Exception as e: + logging.error('failed to make movie thumbnail: %(msg)s' % { + 'msg': unicode(e)}, exc_info=True) diff --git a/motioneye/tzctl.py b/motioneye/tzctl.py new file mode 100644 index 0000000..ff36ab7 --- /dev/null +++ b/motioneye/tzctl.py @@ -0,0 +1,139 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import hashlib +import logging +import os +import settings +import subprocess + +from config import additional_config + + +LOCAL_TIME_FILE = settings.LOCAL_TIME_FILE # @UndefinedVariable + + +def _get_time_zone_symlink(): + file = settings.LOCAL_TIME_FILE + if not file: + return None + + for i in xrange(8): # recursively follow the symlinks @UnusedVariable + try: + file = os.readlink(file) + + except OSError: + break + + if file and file.startswith('/usr/share/zoneinfo/'): + file = file[20:] + + else: + file = None + + time_zone = file or None + if time_zone: + logging.debug('found time zone by symlink method: %s' % time_zone) + + return time_zone + + +def _get_time_zone_md5(): + if settings.LOCAL_TIME_FILE: + return None + + try: + output = subprocess.check_output('cd /usr/share/zoneinfo; find * -type f | xargs md5sum', shell=True) + + except Exception as e: + logging.error('getting md5 of zoneinfo files failed: %s' % e) + + return None + + lines = [l for l in output.split('\n') if l] + lines = [l.split(None, 1) for l in lines] + time_zone_by_md5 = dict(lines) + + try: + with open(settings.LOCAL_TIME_FILE, 'r') as f: + data = f.read() + + except Exception as e: + logging.error('failed to read local time file: %s' % e) + + return None + + md5 = hashlib.md5(data).hexdigest() + time_zone = time_zone_by_md5.get(md5) + + if time_zone: + logging.debug('found time zone by md5 method: %s' % time_zone) + + return time_zone + + +def _get_time_zone(): + return _get_time_zone_symlink() or _get_time_zone_md5() or 'UTC' + + +def _set_time_zone(time_zone): + time_zone = time_zone or 'UTC' + + zoneinfo_file = '/usr/share/zoneinfo/' + time_zone + if not os.path.exists(zoneinfo_file): + logging.error('%s file does not exist' % zoneinfo_file) + + return False + + logging.debug('linking "%s" to "%s"' % (settings.LOCAL_TIME_FILE, zoneinfo_file)) + + try: + os.remove(settings.LOCAL_TIME_FILE) + + except: + pass # nevermind + + try: + os.symlink(zoneinfo_file, settings.LOCAL_TIME_FILE) + + return True + + except Exception as e: + logging.error('failed to link "%s" to "%s": %s' % (settings.LOCAL_TIME_FILE, zoneinfo_file, e)) + + return False + + +@additional_config +def timeZone(): + if not LOCAL_TIME_FILE: + return + + import pytz + timezones = pytz.common_timezones + + return { + 'label': 'Time Zone', + 'description': 'selecting the right timezone assures a correct timestamp displayed on pictures and movies', + 'type': 'choices', + 'choices': [(t, t) for t in timezones], + 'section': 'general', + 'advanced': True, + 'reboot': True, + 'get': _get_time_zone, + 'set': _set_time_zone + } diff --git a/motioneye/update.py b/motioneye/update.py new file mode 100644 index 0000000..0c53b25 --- /dev/null +++ b/motioneye/update.py @@ -0,0 +1,67 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import re + + +# versions + +def get_version(): + import motioneye + + return motioneye.VERSION + + +def get_all_versions(): + return [] + + +def compare_versions(version1, version2): + version1 = re.sub('[^0-9.]', '', version1) + version2 = re.sub('[^0-9.]', '', version2) + + version1 = [int(n) for n in version1.split('.')] + version2 = [int(n) for n in version2.split('.')] + + len1 = len(version1) + len2 = len(version2) + length = min(len1, len2) + for i in xrange(length): + p1 = version1[i] + p2 = version2[i] + + if p1 < p2: + return -1 + + elif p1 > p2: + return 1 + + if len1 < len2: + return -1 + + elif len1 > len2: + return 1 + + else: + return 0 + + +def perform_update(version): + logging.error('updating is not implemented') + + return False diff --git a/motioneye/utils.py b/motioneye/utils.py new file mode 100644 index 0000000..a3fe0b8 --- /dev/null +++ b/motioneye/utils.py @@ -0,0 +1,681 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import base64 +import datetime +import functools +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 +from tornado.ioloop import IOLoop + +import settings + + +try: + from collections import OrderedDict # @UnusedImport + +except: + from ordereddict import OrderedDict # @UnusedImport @Reimport + + +_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') + ')' + + if isinstance(date_time, int): + return pretty_date_time(datetime.datetime.fromtimestamp(date_time)) + + if short: + text = u'{day} {month}, {hm}'.format( + day=date_time.day, + month=date_time.strftime('%b'), + hm=date_time.strftime('%H:%M') + ) + + else: + text = u'{day} {month} {year}, {hm}'.format( + day=date_time.day, + month=date_time.strftime('%B'), + year=date_time.year, + hm=date_time.strftime('%H:%M') + ) + + if tzinfo: + offset = tzinfo.utcoffset(datetime.datetime.utcnow()).seconds + tz = 'GMT' + if offset >= 0: + tz += '+' + + else: + tz += '-' + offset = -offset + + tz += '%.2d' % (offset / 3600) + ':%.2d' % ((offset % 3600) / 60) + + text += ' (' + tz + ')' + + return text + + +def pretty_date(date): + if date is None: + return '('+ _('never') + ')' + + if isinstance(date, int): + return pretty_date(datetime.datetime.fromtimestamp(date)) + + return u'{day} {month} {year}'.format( + day=date.day, + month=_(date.strftime('%B')), + year=date.year + ) + + +def pretty_time(time): + if time is None: + return '' + + if isinstance(time, datetime.timedelta): + hour = time.seconds / 3600 + minute = (time.seconds % 3600) / 60 + time = datetime.time(hour, minute) + + return '{hm}'.format( + hm=time.strftime('%H:%M') + ) + + +def pretty_duration(duration): + if duration is None: + duration = 0 + + if isinstance(duration, datetime.timedelta): + duration = duration.seconds + duration.days * 86400 + + if duration < 0: + negative = True + duration = -duration + + else: + negative = False + + days = int(duration / 86400) + duration %= 86400 + hours = int(duration / 3600) + duration %= 3600 + minutes = int(duration / 60) + duration %= 60 + seconds = duration + + # treat special cases + special_result = None + if days != 0 and hours == 0 and minutes == 0 and seconds == 0: + if days == 1: + special_result = str(days) + ' ' + _('day') + + elif days == 7: + special_result = '1 ' + _('week') + + elif days in [30, 31, 32]: + special_result = '1 ' + _('month') + + elif days in [365, 366]: + special_result = '1 ' + _('year') + + else: + special_result = str(days) + ' ' + _('days') + + elif days == 0 and hours != 0 and minutes == 0 and seconds == 0: + if hours == 1: + special_result = str(hours) + ' ' + _('hour') + + else: + special_result = str(hours) + ' ' + _('hours') + + elif days == 0 and hours == 0 and minutes != 0 and seconds == 0: + if minutes == 1: + special_result = str(minutes) + ' ' + _('minute') + + else: + special_result = str(minutes) + ' ' + _('minutes') + + elif days == 0 and hours == 0 and minutes == 0 and seconds != 0: + if seconds == 1: + special_result = str(seconds) + ' ' + _('second') + + else: + special_result = str(seconds) + ' ' + _('seconds') + + elif days == 0 and hours == 0 and minutes == 0 and seconds == 0: + special_result = str(0) + ' ' + _('seconds') + + if special_result: + if negative: + special_result = _('minus') + ' ' + special_result + + return special_result + + if days: + format = "{d}d{h}h{m}m" + + elif hours: + format = "{h}h{m}m" + + elif minutes: + format = "{m}m" + if seconds: + format += "{s}s" + + else: + format = "{s}s" + + if negative: + format = '-' + format + + return format.format(d=days, h=hours, m=minutes, s=seconds) + + +def pretty_size(size): + if size < 1024: # less than 1kB + size, unit = size, 'B' + + elif size < 1024 * 1024: # less than 1MB + size, unit = size / 1024.0, 'kB' + + elif size < 1024 * 1024 * 1024: # less than 1GB + size, unit = size / 1024.0 / 1024, 'MB' + + else: # greater than or equal to 1GB + size, unit = size / 1024.0 / 1024 / 1024, 'GB' + + return '%.1f %s' % (size, unit) + + +def pretty_http_error(response): + if response.code == 401 or response.error == 'Authentication Error': + return 'authentication failed' + + if not response.error: + return 'ok' + + msg = unicode(response.error) + if msg.startswith('HTTP '): + msg = msg.split(':', 1)[-1].strip() + + if msg.startswith('[Errno '): + msg = msg.split(']', 1)[-1].strip() + + if 'timeout' in msg.lower() or 'timed out' in msg.lower(): + msg = 'request timed out' + + return msg + + +def make_str(s): + if isinstance(s, str): + return s + + try: + return str(s) + + except: + try: + return unicode(s, encoding='utf8').encode('utf8') + + except: + return unicode(s).encode('utf8') + + +def make_unicode(s): + if isinstance(s, unicode): + return s + + try: + return unicode(s, encoding='utf8') + + except: + try: + return unicode(s) + + except: + return str(s).decode('utf8') + + +def get_disk_usage(path): + logging.debug('getting disk usage for path %(path)s...' % { + 'path': path}) + + try: + result = os.statvfs(path) + + except OSError as e: + logging.error('failed to execute statvfs: %(msg)s' % {'msg': unicode(e)}) + + return None + + block_size = result.f_frsize + free_blocks = result.f_bfree + total_blocks = result.f_blocks + + free_size = free_blocks * block_size + total_size = total_blocks * block_size + used_size = total_size - free_size + + return (used_size, total_size) + + +def local_motion_camera(config): + '''Tells if a camera is managed by the local motion instance.''' + return bool(config.get('videodevice') or config.get('netcam_url')) + + +def remote_camera(config): + '''Tells if a camera is managed by a remote motionEye server.''' + return config.get('@proto') == 'motioneye' + + +def v4l2_camera(config): + '''Tells if a camera is a v4l2 device managed by the local motion instance.''' + return bool(config.get('videodevice')) + + +def net_camera(config): + '''Tells if a camera is a network camera managed by the local motion instance.''' + return bool(config.get('netcam_url')) + + +def simple_mjpeg_camera(config): + '''Tells if a camera is a simple MJPEG camera not managed by any motion instance.''' + return bool(config.get('@proto') == 'mjpeg') + + +def test_mjpeg_url(data, auth_modes, allow_jpeg, callback): + data = dict(data) + data.setdefault('scheme', 'http') + data.setdefault('host', '127.0.0.1') + data.setdefault('port', '80') + 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] + status_2xx = [False] + http_11 = [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]: + content_type = header.split(':')[1].strip() + called[0] = True + + if content_type in ['image/jpg', 'image/jpeg', 'image/pjpg'] and allow_jpeg: + callback([{'id': 1, 'name': 'JPEG Network Camera', 'keep_alive': http_11[0]}]) + + elif content_type.startswith('multipart/x-mixed-replace'): + callback([{'id': 1, 'name': 'MJPEG Network Camera', 'keep_alive': http_11[0]}]) + + else: + callback(error='not a supported network camera') + + else: + # check for the status header + m = re.match('^http/1.(\d) (\d+) ', header) + if m: + if int(m.group(2)) / 100 == 2: + status_2xx[0] = True + + if m.group(1) == '1': + http_11[0] = True + + def on_response(response): + if not called[0]: + if response.code == 401 and auth_modes and data['username']: + status_2xx[0] = False + do_request(on_response) + + else: + called[0] = True + callback(error=pretty_http_error(response) if response.error else 'not a supported network camera') + + username = data['username'] or None + password = data['password'] or None + + 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] + timeout = [None] + stream = None + + def connect(): + logging.debug('testing rtsp netcam at %s' % url) + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) + s.settimeout(settings.MJPG_CLIENT_TIMEOUT) + stream = IOStream(s) + stream.set_close_callback(on_close) + stream.connect((data['host'], int(data['port'])), on_connect) + + timeout[0] = IOLoop.instance().add_timeout(datetime.timedelta(seconds=settings.MJPG_CLIENT_TIMEOUT), + functools.partial(on_connect, _timeout=True)) + + return stream + + def on_connect(_timeout=False): + IOLoop.instance().remove_timeout(timeout[0]) + + if _timeout: + return handle_error('timeout connecting to rtsp netcam') + + if not stream: + return handle_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) + timeout[0] = IOLoop.instance().add_timeout(datetime.timedelta(seconds=settings.MJPG_CLIENT_TIMEOUT), on_rtsp) + + def on_rtsp(data): + IOLoop.instance().remove_timeout(timeout[0]) + + if data: + if data.endswith('200 '): + seek_server() + + else: + handle_error('rtsp netcam returned erroneous response: %s' % data) + + else: + handle_error('timeout waiting for rtsp netcam response') + + def seek_server(): + if check_error(): + return + + stream.read_until_regex('Server: .*', on_server) + timeout[0] = IOLoop.instance().add_timeout(datetime.timedelta(seconds=1), on_server) + + def on_server(data=None): + IOLoop.instance().remove_timeout(timeout[0]) + + if data: + identifier = re.findall('Server: (.*)', data)[0].strip() + logging.debug('rtsp netcam identifier is "%s"' % identifier) + + else: + identifier = None + logging.debug('no rtsp netcam 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 identifier: + identifier = ' ' + identifier + + else: + identifier = '' + + if 'udp' in rtsp_support: + cameras.append({'id': 'udp', 'name': '%sRTSP/UDP Camera' % identifier}) + + if 'tcp' in rtsp_support: + cameras.append({'id': 'tcp', 'name': '%sRTSP/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: + 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')] + query.sort(key=lambda q: q[0]) + # "safe" characters here are set to match the encodeURIComponent JavaScript counterpart + query = [(n, urllib.quote(v, safe="!'()*~")) for (n, v) in query] + query = '&'.join([(q[0] + '=' + q[1]) for q in query]) + parts[0] = parts[1] = '' + parts[3] = query + uri = urlparse.urlunsplit(parts) + uri = _SIGNATURE_REGEX.sub('-', uri) + key = _SIGNATURE_REGEX.sub('-', key) + + if body and body.startswith('---'): + body = None # file attachment + + body = body and _SIGNATURE_REGEX.sub('-', body.decode('utf8')) + + return hashlib.sha1('%s:%s:%s:%s' % (method, uri, body or '', key)).hexdigest().lower() + + +def build_basic_header(username, password): + return 'Basic ' + base64.encodestring('%s:%s' % (username, password)).replace('\n', '') + + +def build_digest_header(method, url, username, password, state): + realm = state['realm'] + nonce = state['nonce'] + last_nonce = state.get('last_nonce', '') + nonce_count = state.get('nonce_count', 0) + qop = state.get('qop') + algorithm = state.get('algorithm') + opaque = state.get('opaque') + + if algorithm is None: + _algorithm = 'MD5' + + else: + _algorithm = algorithm.upper() + + if _algorithm == 'MD5' or _algorithm == 'MD5-SESS': + def md5_utf8(x): + if isinstance(x, str): + x = x.encode('utf-8') + return hashlib.md5(x).hexdigest() + hash_utf8 = md5_utf8 + + elif _algorithm == 'SHA': + def sha_utf8(x): + if isinstance(x, str): + x = x.encode('utf-8') + return hashlib.sha1(x).hexdigest() + hash_utf8 = sha_utf8 + + KD = lambda s, d: hash_utf8("%s:%s" % (s, d)) + + if hash_utf8 is None: + return None + + entdig = None + p_parsed = urlparse.urlparse(url) + path = p_parsed.path + if p_parsed.query: + path += '?' + p_parsed.query + + A1 = '%s:%s:%s' % (username, realm, password) + A2 = '%s:%s' % (method, path) + + HA1 = hash_utf8(A1) + HA2 = hash_utf8(A2) + + if nonce == last_nonce: + nonce_count += 1 + + else: + nonce_count = 1 + + ncvalue = '%08x' % nonce_count + s = str(nonce_count).encode('utf-8') + s += nonce.encode('utf-8') + s += time.ctime().encode('utf-8') + s += os.urandom(8) + + cnonce = (hashlib.sha1(s).hexdigest()[:16]) + if _algorithm == 'MD5-SESS': + HA1 = hash_utf8('%s:%s:%s' % (HA1, nonce, cnonce)) + + if qop is None: + respdig = KD(HA1, "%s:%s" % (nonce, HA2)) + + elif qop == 'auth' or 'auth' in qop.split(','): + noncebit = "%s:%s:%s:%s:%s" % ( + nonce, ncvalue, cnonce, 'auth', HA2 + ) + respdig = KD(HA1, noncebit) + + else: + return None + + last_nonce = nonce + + base = 'username="%s", realm="%s", nonce="%s", uri="%s", ' \ + 'response="%s"' % (username, realm, nonce, path, respdig) + if opaque: + base += ', opaque="%s"' % opaque + if algorithm: + base += ', algorithm="%s"' % algorithm + if entdig: + base += ', digest="%s"' % entdig + if qop: + base += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) + + state['last_nonce'] = last_nonce + state['nonce_count'] = nonce_count + + return 'Digest %s' % (base) diff --git a/motioneye/v4l2ctl.py b/motioneye/v4l2ctl.py new file mode 100644 index 0000000..10637f4 --- /dev/null +++ b/motioneye/v4l2ctl.py @@ -0,0 +1,418 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import fcntl +import logging +import os.path +import re +import stat +import subprocess +import time +import utils + + +_resolutions_cache = {} +_ctrls_cache = {} +_ctrl_values_cache = {} + +_DEV_V4L_BY_ID = '/dev/v4l/by-id/' + + +def find_v4l2_ctl(): + try: + return subprocess.check_output('which v4l2-ctl', shell=True).strip() + + except subprocess.CalledProcessError: # not found + return None + + +def list_devices(): + global _resolutions_cache, _ctrls_cache, _ctrl_values_cache + + logging.debug('listing v4l2 devices...') + + try: + output = '' + started = time.time() + p = subprocess.Popen('v4l2-ctl --list-devices 2>/dev/null', shell=True, stdout=subprocess.PIPE, bufsize=1) + + fd = p.stdout.fileno() + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + + while True: + try: + data = p.stdout.read(1024) + if not data: + break + + except IOError: + data = '' + time.sleep(0.01) + + output += data + + if len(output) > 10240: + logging.warn('v4l2-ctl command returned more than 10k of output') + break + + if time.time() - started > 3: + logging.warn('v4l2-ctl command ran for more than 3 seconds') + break + + except subprocess.CalledProcessError: + logging.debug('failed to list devices (probably no devices installed)') + return [] + + try: + # try to kill the v4l2-ctl subprocess + p.kill() + + except: + pass # nevermind + + name = None + devices = [] + for line in output.split('\n'): + if line.startswith('\t'): + device = line.strip() + persistent_device = find_persistent_device(device) + devices.append((device, persistent_device, name)) + + logging.debug('found device %(name)s: %(device)s, %(persistent_device)s' % { + 'name': name, 'device': device, 'persistent_device': persistent_device}) + + else: + name = line.split('(')[0].strip() + + # clear the cache + _resolutions_cache = {} + _ctrls_cache = {} + _ctrl_values_cache = {} + + return devices + + +def list_resolutions(device): + global _resolutions_cache + + device = utils.make_str(device) + + if device in _resolutions_cache: + return _resolutions_cache[device] + + logging.debug('listing resolutions of device %(device)s...' % {'device': device}) + + resolutions = set() + output = '' + started = time.time() + p = subprocess.Popen('v4l2-ctl -d "%(device)s" --list-formats-ext | grep -vi stepwise | grep -oE "[0-9]+x[0-9]+" || true' % { + 'device': device}, shell=True, stdout=subprocess.PIPE, bufsize=1) + + fd = p.stdout.fileno() + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + + while True: + try: + data = p.stdout.read(1024) + if not data: + break + + except IOError: + data = '' + time.sleep(0.01) + + output += data + + if len(output) > 10240: + logging.warn('v4l2-ctl command returned more than 10k of output') + break + + if time.time() - started > 3: + logging.warn('v4l2-ctl command ran for more than 3 seconds') + break + + try: + # try to kill the v4l2-ctl subprocess + p.kill() + + except: + pass # nevermind + + for pair in output.split('\n'): + pair = pair.strip() + if not pair: + continue + + width, height = pair.split('x') + width = int(width) + height = int(height) + + if (width, height) in resolutions: + continue # duplicate resolution + + if width < 96 or height < 96: # some reasonable minimal values + continue + + if width % 16 or height % 16: # ignore non-modulo 16 resolutions + continue + + resolutions.add((width, height)) + + logging.debug('found resolution %(width)sx%(height)s for device %(device)s' % { + 'device': device, 'width': width, 'height': height}) + + if not resolutions: + 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 = utils.COMMON_RESOLUTIONS + + resolutions = list(sorted(resolutions, key=lambda r: (r[0], r[1]))) + _resolutions_cache[device] = resolutions + + return resolutions + + +def device_present(device): + device = utils.make_str(device) + + try: + st = os.stat(device) + return stat.S_ISCHR(st.st_mode) + + except: + return False + + +def find_persistent_device(device): + device = utils.make_str(device) + + try: + devs_by_id = os.listdir(_DEV_V4L_BY_ID) + + except OSError: + return device + + for p in devs_by_id: + p = os.path.join(_DEV_V4L_BY_ID, p) + if os.path.realpath(p) == device: + return p + + return device + + +def get_brightness(device): + return _get_ctrl(device, 'brightness') + + +def set_brightness(device, value): + _set_ctrl(device, 'brightness', value) + + +def get_contrast(device): + return _get_ctrl(device, 'contrast') + + +def set_contrast(device, value): + _set_ctrl(device, 'contrast', value) + + +def get_saturation(device): + return _get_ctrl(device, 'saturation') + + +def set_saturation(device, value): + _set_ctrl(device, 'saturation', value) + + +def get_hue(device): + return _get_ctrl(device, 'hue') + + +def set_hue(device, value): + _set_ctrl(device, 'hue', value) + + +def _get_ctrl(device, control): + global _ctrl_values_cache + + device = utils.make_str(device) + + if not device_present(device): + return None + + if device in _ctrl_values_cache and control in _ctrl_values_cache[device]: + return _ctrl_values_cache[device][control] + + controls = _list_ctrls(device) + properties = controls.get(control) + if properties is None: + logging.warn('control %(control)s not found for device %(device)s' % { + 'control': control, 'device': device}) + + return None + + value = int(properties['value']) + + # adjust the value range + if 'min' in properties and 'max' in properties: + min_value = int(properties['min']) + max_value = int(properties['max']) + + value = int(round((value - min_value) * 100.0 / (max_value - min_value))) + + else: + logging.warn('min and max values not found for control %(control)s of device %(device)s' % { + 'control': control, 'device': device}) + + logging.debug('control %(control)s of device %(device)s is %(value)s%%' % { + 'control': control, 'device': device, 'value': value}) + + return value + + +def _set_ctrl(device, control, value): + global _ctrl_values_cache + + device = utils.make_str(device) + + if not device_present(device): + return + + controls = _list_ctrls(device) + properties = controls.get(control) + if properties is None: + logging.warn('control %(control)s not found for device %(device)s' % { + 'control': control, 'device': device}) + + return + + _ctrl_values_cache.setdefault(device, {})[control] = value + + # adjust the value range + if 'min' in properties and 'max' in properties: + min_value = int(properties['min']) + max_value = int(properties['max']) + + value = int(round(min_value + value * (max_value - min_value) / 100.0)) + + else: + logging.warn('min and max values not found for control %(control)s of device %(device)s' % { + 'control': control, 'device': device}) + + logging.debug('setting control %(control)s of device %(device)s to %(value)s' % { + 'control': control, 'device': device, 'value': value}) + + output = '' + started = time.time() + p = subprocess.Popen('v4l2-ctl -d "%(device)s" --set-ctrl %(control)s=%(value)s' % { + 'device': device, 'control': control, 'value': value}, shell=True, stdout=subprocess.PIPE, bufsize=1) + + fd = p.stdout.fileno() + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + + while True: + try: + data = p.stdout.read(1024) + if not data: + break + + except IOError: + data = '' + time.sleep(0.01) + + output += data + + if len(output) > 10240: + logging.warn('v4l2-ctl command returned more than 10k of output') + break + + if time.time() - started > 3: + logging.warn('v4l2-ctl command ran for more than 3 seconds') + break + + try: + # try to kill the v4l2-ctl subprocess + p.kill() + + except: + pass # nevermind + + +def _list_ctrls(device): + global _ctrls_cache + + device = utils.make_str(device) + + if device in _ctrls_cache: + return _ctrls_cache[device] + + output = '' + started = time.time() + p = subprocess.Popen('v4l2-ctl -d "%(device)s" --list-ctrls' % { + 'device': device}, shell=True, stdout=subprocess.PIPE, bufsize=1) + + fd = p.stdout.fileno() + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + + while True: + try: + data = p.stdout.read(1024) + if not data: + break + + except IOError: + data = '' + time.sleep(0.01) + + output += data + + if len(output) > 10240: + logging.warn('v4l2-ctl command returned more than 10k of output') + break + + if time.time() - started > 3: + logging.warn('v4l2-ctl command ran for more than 3 seconds') + break + + try: + # try to kill the v4l2-ctl subprocess + p.kill() + + except: + pass # nevermind + + controls = {} + for line in output.split('\n'): + if not line: + continue + + match = re.match('^\s*(\w+)\s+\(\w+\)\s+\:\s*(.+)', line) + if not match: + continue + + (control, properties) = match.groups() + properties = dict([v.split('=', 1) for v in properties.split(' ') if v.count('=')]) + controls[control] = properties + + _ctrls_cache[device] = controls + + return controls diff --git a/motioneye/webhook.py b/motioneye/webhook.py new file mode 100755 index 0000000..016a0db --- /dev/null +++ b/motioneye/webhook.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import sys +import urllib2 +import urlparse + +import settings + +from motioneye import _configure_settings, _configure_logging + + +_configure_settings() +_configure_logging() + + +def print_usage(): + print 'Usage: webhook.py ' + + +if __name__ == '__main__': + if len(sys.argv) < 3: + print_usage() + sys.exit(-1) + + method = sys.argv[1] + url = sys.argv[2] + + logging.debug('method = %s' % method) + logging.debug('url = %s' % url) + + if method == 'POST': + parts = urlparse.urlparse(url) + data = parts.query + + else: + data = None + + request = urllib2.Request(url, data) + try: + urllib2.urlopen(request, timeout=settings.REMOTE_REQUEST_TIMEOUT) + logging.debug('webhook successfully called') + + except Exception as e: + logging.error('failed to call webhook: %s' % e) diff --git a/motioneye/wifictl.py b/motioneye/wifictl.py new file mode 100644 index 0000000..32ec562 --- /dev/null +++ b/motioneye/wifictl.py @@ -0,0 +1,248 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import re +import settings + +from config import additional_config, additional_section + + +WPA_SUPPLICANT_CONF = settings.WPA_SUPPLICANT_CONF # @UndefinedVariable + + +def _get_wifi_settings(): + # will return the first configured network + + logging.debug('reading wifi settings from %s' % WPA_SUPPLICANT_CONF) + + try: + conf_file = open(WPA_SUPPLICANT_CONF, 'r') + + except Exception as e: + logging.error('could open wifi settings file %(path)s: %(msg)s' % { + 'path': WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) + + return { + 'wifiEnabled': False, + 'wifiNetworkName': '', + 'wifiNetworkKey': '' + } + + lines = conf_file.readlines() + conf_file.close() + + ssid = psk = '' + in_section = False + for line in lines: + line = line.strip() + if line.startswith('#'): + continue + + if '{' in line: + in_section = True + + elif '}' in line: + in_section = False + break + + elif in_section: + m = re.search('ssid\s*=\s*"(.*?)"', line) + if m: + ssid = m.group(1) + + m = re.search('psk\s*=\s*"(.*?)"', line) + if m: + psk = m.group(1) + + if ssid: + logging.debug('wifi is enabled (ssid = "%s")' % ssid) + + return { + 'wifiEnabled': True, + 'wifiNetworkName': ssid, + 'wifiNetworkKey': psk + } + + else: + logging.debug('wifi is disabled') + + return { + 'wifiEnabled': False, + 'wifiNetworkName': ssid, + 'wifiNetworkKey': psk + } + + +def _set_wifi_settings(s): + s.setdefault('wifiEnabled', False) + s.setdefault('wifiNetworkName', '') + s.setdefault('wifiNetworkKey', '') + + logging.debug('writing wifi settings to %s: enabled=%s, ssid="%s"' % ( + WPA_SUPPLICANT_CONF, s['wifiEnabled'], s['wifiNetworkName'])) + + enabled = s['wifiEnabled'] + ssid = s['wifiNetworkName'] + psk = s['wifiNetworkKey'] + + # will update the first configured network + try: + conf_file = open(WPA_SUPPLICANT_CONF, 'r') + + except Exception as e: + logging.error('could open wifi settings file %(path)s: %(msg)s' % { + 'path': WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) + + return + + lines = conf_file.readlines() + conf_file.close() + + in_section = False + found_ssid = False + found_psk = False + i = 0 + while i < len(lines): + line = lines[i].strip() + if line.startswith('#'): + i += 1 + continue + + if '{' in line: + in_section = True + + elif '}' in line: + in_section = False + if enabled and ssid and not found_ssid: + lines.insert(i, ' ssid="' + ssid + '"\n') + if enabled and psk and not found_psk: + lines.insert(i, ' psk="' + psk + '"\n') + + found_psk = found_ssid = True + + break + + elif in_section: + if enabled: + if re.match('ssid\s*=\s*".*?"', line): + lines[i] = ' ssid="' + ssid + '"\n' + found_ssid = True + + elif re.match('psk\s*=\s*".*?"', line): + if psk: + lines[i] = ' psk="' + psk + '"\n' + found_psk = True + + else: + lines.pop(i) + i -= 1 + + else: # wifi disabled + if re.match('ssid\s*=\s*".*?"', line) or re.match('psk\s*=\s*".*?"', line): + lines.pop(i) + i -= 1 + + i += 1 + + if enabled and not found_ssid: + lines.append('network={\n') + lines.append(' scan_ssid=1\n') + lines.append(' ssid="' + ssid + '"\n') + lines.append(' psk="' + psk + '"\n') + lines.append('}\n\n') + + try: + conf_file = open(WPA_SUPPLICANT_CONF, 'w') + + except Exception as e: + logging.error('could open wifi settings file %(path)s: %(msg)s' % { + 'path': WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) + + return + + for line in lines: + conf_file.write(line) + + conf_file.close() + + +@additional_section +def network(): + return { + 'label': 'Network', + 'description': 'configure the network connection', + 'advanced': True + } + + +@additional_config +def wifiEnabled(): + if not WPA_SUPPLICANT_CONF: + return + + return { + 'label': 'Wireless Network', + 'description': 'enable this if you want to connect to a wireless network', + 'type': 'bool', + 'section': 'network', + 'advanced': True, + 'reboot': True, + 'get': _get_wifi_settings, + 'set': _set_wifi_settings, + 'get_set_dict': True + } + + +@additional_config +def wifiNetworkName(): + if not WPA_SUPPLICANT_CONF: + return + + return { + 'label': 'Wireless Network Name', + 'description': 'the name (SSID) of your wireless network', + 'type': 'str', + 'section': 'network', + 'advanced': True, + 'required': True, + 'reboot': True, + 'depends': ['wifiEnabled'], + 'get': _get_wifi_settings, + 'set': _set_wifi_settings, + 'get_set_dict': True + } + + +@additional_config +def wifiNetworkKey(): + if not WPA_SUPPLICANT_CONF: + return + + return { + 'label': 'Wireless Network Key', + 'description': 'the key (PSK) required to connect to your wireless network', + 'type': 'pwd', + 'section': 'network', + 'advanced': True, + 'required': False, + 'reboot': True, + 'depends': ['wifiEnabled'], + 'get': _get_wifi_settings, + 'set': _set_wifi_settings, + 'get_set_dict': True + } diff --git a/motioneye/wsswitch.py b/motioneye/wsswitch.py new file mode 100644 index 0000000..be65c27 --- /dev/null +++ b/motioneye/wsswitch.py @@ -0,0 +1,116 @@ + +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import logging +import tornado + +import config +import motionctl +import utils + + +def start(): + ioloop = tornado.ioloop.IOLoop.instance() + ioloop.add_timeout(datetime.timedelta(seconds=10), _check_ws) + + +def _during_working_schedule(now, working_schedule): + parts = working_schedule.split('|') + if len(parts) < 7: + return False # invalid ws + + ws_day = parts[now.weekday()] + parts = ws_day.split('-') + if len(parts) != 2: + return False # invalid ws + + _from, to = parts + if not _from or not to: + return False # ws disabled for this day + + _from = _from.split(':') + to = to.split(':') + if len(_from) != 2 or len(to) != 2: + return False # invalid ws + + try: + from_h = int(_from[0]) + from_m = int(_from[1]) + to_h = int(to[0]) + to_m = int(to[1]) + + except ValueError: + return False # invalid ws + + if now.hour < from_h or now.hour > to_h: + return False + + if now.hour == from_h and now.minute < from_m: + return False + + if now.hour == to_h and now.minute > to_m: + return False + + return True + + +def _check_ws(): + # schedule the next call + ioloop = tornado.ioloop.IOLoop.instance() + ioloop.add_timeout(datetime.timedelta(seconds=10), _check_ws) + + if not motionctl.running(): + return + + now = datetime.datetime.now() + for camera_id in config.get_camera_ids(): + camera_config = config.get_camera(camera_id) + if not utils.local_motion_camera(camera_config): + continue + + working_schedule = camera_config.get('@working_schedule') + motion_detection = camera_config.get('@motion_detection') + working_schedule_type = camera_config.get('@working_schedule_type') or 'outside' + + if not working_schedule: # working schedule disabled, motion detection left untouched + continue + + if not motion_detection: # motion detection explicitly disabled + continue + + now_during = _during_working_schedule(now, working_schedule) + must_be_enabled = (now_during and working_schedule_type == 'during') or (not now_during and working_schedule_type == 'outside') + + currently_enabled = motionctl.get_motion_detection(camera_id) + if currently_enabled is None: # could not detect current status + logging.warn('skipping motion detection status update for camera with id %(id)s' % {'id': camera_id}) + continue + + if currently_enabled and not must_be_enabled: + logging.debug('must disable motion detection for camera with id %(id)s (%(what)s working schedule)' % { + 'id': camera_id, + 'what': working_schedule_type}) + + motionctl.set_motion_detection(camera_id, False) + + elif not currently_enabled and must_be_enabled: + logging.debug('must enable motion detection for camera with id %(id)s (%(what)s working schedule)' % { + 'id': camera_id, + 'what': working_schedule_type}) + + motionctl.set_motion_detection(camera_id, True) diff --git a/sendmail.py b/sendmail.py deleted file mode 100755 index 4844c08..0000000 --- a/sendmail.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import datetime -import logging -import os -import re -import smtplib -import socket -import sys -import time - -from email import Encoders -from email.mime.text import MIMEText -from email.MIMEMultipart import MIMEMultipart -from email.MIMEBase import MIMEBase -from tornado.ioloop import IOLoop - -import settings - -from motioneye import _configure_settings, _configure_logging, _configure_signals - -_configure_settings() -_configure_signals() -_configure_logging(module='sendmail') - -import config -import mediafiles -import tzctl - - -messages = { - 'motion_start': 'Motion has been detected by camera "%(camera)s/%(hostname)s" at %(moment)s (%(timezone)s).' -} - -subjects = { - 'motion_start': 'motionEye: motion detected by "%(camera)s"' -} - - -def send_mail(server, port, account, password, tls, to, subject, message, files): - conn = smtplib.SMTP(server, port, timeout=getattr(settings, 'SMTP_TIMEOUT', 60)) - if tls: - conn.starttls() - - if account and password: - conn.login(account, password) - - _from = account or 'motioneye@' + socket.gethostname() - - email = MIMEMultipart() - email['Subject'] = subject - email['From'] = _from - email['To'] = ', '.join(to) - email.attach(MIMEText(message)) - - for file in reversed(files): - part = MIMEBase('image', 'jpeg') - with open(file, 'rb') as f: - part.set_payload(f.read()) - - Encoders.encode_base64(part) - part.add_header('Content-Disposition', 'attachment; filename="%s"' % os.path.basename(file)) - email.attach(part) - - if files: - logging.debug('attached %d pictures' % len(files)) - - logging.debug('sending email message') - conn.sendmail(_from, to, email.as_string()) - conn.quit() - - -def make_message(subject, message, camera_id, moment, timespan, callback): - camera_config = config.get_camera(camera_id) - - def on_media_files(media_files): - logging.debug('got media files') - - timestamp = time.mktime(moment.timetuple()) - - media_files = [m for m in media_files if abs(m['timestamp'] - timestamp) < timespan] # filter out non-recent media files - media_files.sort(key=lambda m: m['timestamp'], reverse=True) - media_files = [os.path.join(camera_config['target_dir'], re.sub('^/', '', m['path'])) for m in media_files] - - logging.debug('selected %d pictures' % len(media_files)) - - format_dict = { - 'camera': camera_config['@name'], - 'hostname': socket.gethostname(), - 'moment': moment.strftime('%Y-%m-%d %H:%M:%S'), - } - - if settings.LOCAL_TIME_FILE: - format_dict['timezone'] = tzctl._get_time_zone() - - else: - format_dict['timezone'] = 'local time' - - m = message % format_dict - s = subject % format_dict - s = s.replace('\n', ' ') - - m += '\n\n' - m += 'motionEye.' - - callback(s, m, media_files) - - if not timespan: - return on_media_files([]) - - logging.debug('creating email message') - - time.sleep(timespan) # give motion some time to create motion pictures - mediafiles.list_media(camera_config, media_type='picture', callback=on_media_files) - - -def print_usage(): - print 'Usage: sendmail.py [timespan]' - - -if __name__ == '__main__': - if len(sys.argv) < 10: - print_usage() - sys.exit(-1) - - server = sys.argv[1] - port = int(sys.argv[2]) - account = sys.argv[3] - password = sys.argv[4] - tls = sys.argv[5].lower() == 'true' - to = sys.argv[6] - msg_id = sys.argv[7] - camera_id = sys.argv[8] - moment = sys.argv[9] - try: - timespan = int(sys.argv[10]) - - except: - timespan = 0 - - logging.debug('hello!') - - message = messages.get(msg_id) - subject = subjects.get(msg_id) - if not message or not subject: - logging.error('unknown message id') - sys.exit(-1) - - moment = datetime.datetime.strptime(moment, '%Y-%m-%dT%H:%M:%S') - - logging.debug('server = %s' % server) - logging.debug('port = %s' % port) - logging.debug('account = %s' % account) - logging.debug('password = ******') - logging.debug('server = %s' % server) - logging.debug('tls = %s' % tls) - logging.debug('to = %s' % to) - logging.debug('msg_id = %s' % msg_id) - logging.debug('camera_id = %s' % camera_id) - logging.debug('moment = %s' % moment.strftime('%Y-%m-%d %H:%M:%S')) - logging.debug('smtp timeout = %d' % settings.SMTP_TIMEOUT) - logging.debug('timespan = %d' % timespan) - - if not to: - logging.info('no email address specified') - sys.exit(0) - - to = [t.strip() for t in re.split('[,;| ]', to)] - to = [t for t in to if t] - - io_loop = IOLoop.instance() - - def on_message(subject, message, files): - try: - send_mail(server, port, account, password, tls, to, subject, message, files) - logging.info('email sent') - - except Exception as e: - logging.error('failed to send mail: %s' % e, exc_info=True) - - io_loop.stop() - - def ioloop_timeout(): - io_loop.stop() - - make_message(subject, message, camera_id, moment, timespan, on_message) - - io_loop.add_timeout(datetime.timedelta(seconds=settings.SMTP_TIMEOUT), ioloop_timeout) - io_loop.start() - - logging.debug('bye!') \ No newline at end of file diff --git a/settings_default.py b/settings_default.py deleted file mode 100644 index d200cc3..0000000 --- a/settings_default.py +++ /dev/null @@ -1,81 +0,0 @@ - -import logging -import os.path -import sys - -# you normally don't have to change these -PROJECT_PATH = os.path.dirname(sys.argv[0]) -TEMPLATE_PATH = os.path.join(PROJECT_PATH, 'templates') -STATIC_PATH = os.path.join(PROJECT_PATH, 'static') - -# static files (.css, .js etc) are served at this root url -STATIC_URL = '/static/' - -# path to the config directory; must be writable -CONF_PATH = os.path.abspath(os.path.join(PROJECT_PATH, 'conf')) - -# pid files go here -RUN_PATH = os.path.abspath(os.path.join(PROJECT_PATH, 'run')) - -# log files go here -LOG_PATH = os.path.abspath(os.path.join(PROJECT_PATH, 'log')) - -# default output path for media files -MEDIA_PATH = os.path.abspath(os.path.join(PROJECT_PATH, 'media')) - -# path to motion binary (automatically detected if not set) -MOTION_BINARY = None - -# set to logging.DEBUG for verbose output -LOG_LEVEL = logging.INFO - -# set to 127.0.0.1 to restrict access to localhost -LISTEN = '0.0.0.0' - -# change the port according to your requirements/restrictions -PORT = 8765 - -# interval in seconds at which motionEye checks the SMB mounts -MOUNT_CHECK_INTERVAL = 300 - -# interval in seconds at which motionEye checks if motion is running -MOTION_CHECK_INTERVAL = 10 - -# interval in seconds at which the janitor is called to remove old pictures and movies -CLEANUP_INTERVAL = 43200 - -# interval in seconds at which the thumbnail mechanism runs (set to 0 to disable) -THUMBNAILER_INTERVAL = 60 - -# timeout in seconds when waiting for response from a remote motionEye server -REMOTE_REQUEST_TIMEOUT = 10 - -# timeout in seconds when waiting for mjpg data from the motion daemon -MJPG_CLIENT_TIMEOUT = 10 - -# timeout in seconds after which an idle mjpg client is removed (set to 0 to disable) -MJPG_CLIENT_IDLE_TIMEOUT = 10 - -# enable SMB shares (requires root) -SMB_SHARES = False - -# the directory where the SMB mounts will be created -SMB_MOUNT_ROOT = '/media' - -# path to a wpa_supplicant.conf file if wifi settings UI is desired -WPA_SUPPLICANT_CONF = None - -# path to a localtime file if time zone settings UI is desired -LOCAL_TIME_FILE = None - -# enables shutdown and rebooting after changing system settings (such as wifi settings or system updates) -ENABLE_REBOOT = False - -# the timeout in seconds to use when talking to a SMTP server -SMTP_TIMEOUT = 60 - -# the time to wait for zip file creation -ZIP_TIMEOUT = 500 - -# enable adding and removing cameras from UI -ADD_REMOVE_CAMERAS = True diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b963858 --- /dev/null +++ b/setup.py @@ -0,0 +1,64 @@ + +# Always prefer setuptools over distutils +from setuptools import setup +# To use a consistent encoding +from codecs import open +from os import path + +here = path.abspath(path.dirname(__file__)) + +with open(path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +setup( + name='motioneye', + + version='0.25.2', + + description='motionEye server', + long_description=long_description, + + url='https://bitbucket.org/ccrisan/motioneye/', + + author='Calin Crisan', + author_email='ccrisan@gmail.com', + + license='GPLv3', + + # See https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + 'Development Status :: 3 - Beta', + + 'Intended Audience :: End Users/Desktop', + 'Topic :: Multimedia :: Video', + + 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', + + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7' + ], + + keywords='motion video surveillance frontend', + + packages=['motioneye'], + + install_requires=['tornado>=3.1', 'jinja2', 'pillow', 'pycurl'], + + package_data={ + 'motioneye': [ + 'static/*', + 'static/*/*', + 'templates/*' + ] + }, + + data_files=[], + + entry_points={ + 'console_scripts': [ + 'motioneye=motioneye.motioneye:main', + ], + }, +) + diff --git a/src/cleanup.py b/src/cleanup.py deleted file mode 100644 index a032ef1..0000000 --- a/src/cleanup.py +++ /dev/null @@ -1,96 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import datetime -import logging -import multiprocessing -import os -import signal -import tornado - -import mediafiles -import settings -import thumbnailer - - -_process = None - - -def start(): - # schedule the first call a bit later to improve performance at startup - ioloop = tornado.ioloop.IOLoop.instance() - ioloop.add_timeout(datetime.timedelta(seconds=60), _run_process) - - -def stop(): - global _process - - if not running(): - _process = None - return - - if _process.is_alive(): - _process.join(timeout=10) - - if _process.is_alive(): - logging.error('cleanup process did not finish in time, killing it...') - os.kill(_process.pid, signal.SIGKILL) - - _process = None - - -def running(): - return _process is not None and _process.is_alive() - - -def _run_process(): - global _process - - ioloop = tornado.ioloop.IOLoop.instance() - - if thumbnailer.running(): - # postpone if thumbnailer is currently running - ioloop.add_timeout(datetime.timedelta(seconds=60), _run_process) - - return - - else: - # schedule the next call - ioloop.add_timeout(datetime.timedelta(seconds=settings.CLEANUP_INTERVAL), _run_process) - - if not running(): # check that the previous process has finished - logging.debug('running cleanup process...') - - _process = multiprocessing.Process(target=_do_cleanup) - _process.start() - - -def _do_cleanup(): - # this will be executed in a separate subprocess - - # ignore the terminate and interrupt signals in this subprocess - signal.signal(signal.SIGINT, signal.SIG_IGN) - signal.signal(signal.SIGTERM, signal.SIG_IGN) - - try: - mediafiles.cleanup_media('picture') - mediafiles.cleanup_media('movie') - logging.debug('cleanup done') - - except Exception as e: - logging.error('failed to cleanup media files: %(msg)s' % { - 'msg': unicode(e)}, exc_info=True) diff --git a/src/config.py b/src/config.py deleted file mode 100644 index 235d763..0000000 --- a/src/config.py +++ /dev/null @@ -1,1789 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import collections -import datetime -import errno -import logging -import os.path -import re -import shlex -import subprocess -import urlparse - -from tornado.ioloop import IOLoop - -import diskctl -import powerctl -import settings -import update -import utils -import v4l2ctl - -from utils import OrderedDict - - -_CAMERA_CONFIG_FILE_NAME = 'thread-%(id)s.conf' -_MAIN_CONFIG_FILE_NAME = 'motion.conf' - -_main_config_cache = None -_camera_config_cache = {} -_camera_ids_cache = None -_additional_section_funcs = [] -_additional_config_funcs = [] -_additional_structure_cache = {} - -# starting with r490 motion config directives have changed a bit -_LAST_OLD_CONFIG_VERSIONS = (490, '3.2.12') - -_KNOWN_MOTION_OPTIONS = set([ - 'auto_brightness', 'brightness', 'contrast', 'emulate_motion', 'event_gap', 'ffmpeg_bps', 'ffmpeg_output_movies', 'ffmpeg_variable_bitrate', 'ffmpeg_video_codec', - 'framerate', 'height', 'hue', 'lightswitch', 'locate_motion_mode', 'locate_motion_style', 'minimum_motion_frames', 'movie_filename', 'max_movie_time', 'max_mpeg_time', - 'noise_level', 'noise_tune', 'on_event_end', 'on_event_start', 'output_pictures', 'picture_filename', 'post_capture', 'pre_capture', 'quality', 'rotate', 'saturation', - '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', 'rtsp_uses_tcp' -]) - - -def additional_section(func): - _additional_section_funcs.append(func) - - -def additional_config(func): - _additional_config_funcs.append(func) - - -import wifictl # @UnusedImport -import tzctl # @UnusedImport - - -def get_main(as_lines=False): - global _main_config_cache - - if not as_lines and _main_config_cache is not None: - return _main_config_cache - - config_file_path = os.path.join(settings.CONF_PATH, _MAIN_CONFIG_FILE_NAME) - - logging.debug('reading main config from file %(path)s...' % {'path': config_file_path}) - - lines = None - try: - file = open(config_file_path, 'r') - - except IOError as e: - if e.errno == errno.ENOENT: # file does not exist - logging.info('main config file %(path)s does not exist, using default values' % {'path': config_file_path}) - - lines = [] - - else: - logging.error('could not open main config file %(path)s: %(msg)s' % { - 'path': config_file_path, 'msg': unicode(e)}) - - raise - - if lines is None: - try: - lines = [l[:-1] for l in file.readlines()] - - except Exception as e: - logging.error('could not read main config file %(path)s: %(msg)s' % { - 'path': config_file_path, 'msg': unicode(e)}) - - raise - - finally: - file.close() - - if as_lines: - return lines - - main_config = _conf_to_dict(lines, - list_names=['thread'], - no_convert=['@admin_username', '@admin_password', '@normal_username', '@normal_password']) - - _get_additional_config(main_config) - _set_default_motion(main_config, old_motion=is_old_motion()) - - _main_config_cache = main_config - - return main_config - - -def set_main(main_config): - global _main_config_cache - - main_config = dict(main_config) - _set_default_motion(main_config, old_motion=is_old_motion()) - for n, v in _main_config_cache.iteritems(): - main_config.setdefault(n, v) - _main_config_cache = main_config - - main_config = dict(main_config) - _set_additional_config(main_config) - - config_file_path = os.path.join(settings.CONF_PATH, _MAIN_CONFIG_FILE_NAME) - - # read the actual configuration from file - lines = get_main(as_lines=True) - - # write the configuration to file - logging.debug('writing main config to %(path)s...' % {'path': config_file_path}) - - try: - file = open(config_file_path, 'w') - - except Exception as e: - logging.error('could not open main config file %(path)s for writing: %(msg)s' % { - 'path': config_file_path, 'msg': unicode(e)}) - - raise - - lines = _dict_to_conf(lines, main_config, list_names=['thread']) - - try: - file.writelines([utils.make_str(l) + '\n' for l in lines]) - - except Exception as e: - logging.error('could not write main config file %(path)s: %(msg)s' % { - 'path': config_file_path, 'msg': unicode(e)}) - - raise - - finally: - file.close() - - -def get_camera_ids(filter_valid=True): - global _camera_ids_cache - - if _camera_ids_cache is not None: - return _camera_ids_cache - - config_path = settings.CONF_PATH - - logging.debug('listing config dir %(path)s...' % {'path': config_path}) - - try: - ls = os.listdir(config_path) - - except Exception as e: - logging.error('failed to list config dir %(path)s: %(msg)s', { - 'path': config_path, 'msg': unicode(e)}) - - raise - - camera_ids = [] - - pattern = '^' + _CAMERA_CONFIG_FILE_NAME.replace('%(id)s', '(\d+)') + '$' - for name in ls: - match = re.match(pattern, name) - if match: - camera_id = int(match.groups()[0]) - logging.debug('found camera with id %(id)s' % { - 'id': camera_id}) - - camera_ids.append(camera_id) - - camera_ids.sort() - - if not filter_valid: - return camera_ids - - filtered_camera_ids = [] - for camera_id in camera_ids: - if get_camera(camera_id): - filtered_camera_ids.append(camera_id) - - _camera_ids_cache = filtered_camera_ids - - return filtered_camera_ids - - -def get_enabled_local_motion_cameras(): - if not get_main().get('@enabled'): - return [] - - camera_ids = get_camera_ids() - cameras = [get_camera(camera_id) for camera_id in camera_ids] - return [c for c in cameras if c.get('@enabled') and utils.local_motion_camera(c)] - - -def get_network_shares(): - if not get_main().get('@enabled'): - return [] - - camera_ids = get_camera_ids() - cameras = [get_camera(camera_id) for camera_id in camera_ids] - - mounts = [] - for camera in cameras: - if camera.get('@storage_device') != 'network-share': - continue - - mounts.append({ - 'server': camera['@network_server'], - 'share': camera['@network_share_name'], - 'username': camera['@network_username'], - 'password': camera['@network_password'], - }) - - return mounts - - -def get_camera(camera_id, as_lines=False): - global _camera_config_cache - - if not as_lines and camera_id in _camera_config_cache: - return _camera_config_cache[camera_id] - - camera_config_path = os.path.join(settings.CONF_PATH, _CAMERA_CONFIG_FILE_NAME) % {'id': camera_id} - - logging.debug('reading camera config from %(path)s...' % {'path': camera_config_path}) - - try: - file = open(camera_config_path, 'r') - - except Exception as e: - logging.error('could not open camera config file: %(msg)s' % {'msg': unicode(e)}) - - raise - - try: - lines = [l.strip() for l in file.readlines()] - - except Exception as e: - logging.error('could not read camera config file %(path)s: %(msg)s' % { - 'path': camera_config_path, 'msg': unicode(e)}) - - raise - - finally: - file.close() - - if as_lines: - return lines - - camera_config = _conf_to_dict(lines, - no_convert=['@name', '@network_share_name', '@network_server', - '@network_username', '@network_password', '@storage_device']) - - if utils.local_motion_camera(camera_config): - # determine the enabled status - main_config = get_main() - threads = main_config.get('thread', []) - camera_config['@enabled'] = _CAMERA_CONFIG_FILE_NAME % {'id': camera_id} in threads - camera_config['@id'] = camera_id - - old_motion = is_old_motion() - - # adapt directives from old configuration, if needed - if old_motion: - logging.debug('using old motion config directives') - - if 'output_normal' in camera_config: - camera_config['output_pictures'] = camera_config.pop('output_normal') - if 'output_all' in camera_config: - camera_config['emulate_motion'] = camera_config.pop('output_all') - if 'ffmpeg_cap_new' in camera_config: - camera_config['ffmpeg_output_movies'] = camera_config.pop('ffmpeg_cap_new') - if 'locate' in camera_config: - camera_config['locate_motion_mode'] = camera_config.pop('locate') - if 'jpeg_filename' in camera_config: - camera_config['picture_filename'] = camera_config.pop('jpeg_filename') - if 'max_mpeg_time' in camera_config: - camera_config['max_movie_time'] = camera_config.pop('max_mpeg_time') - if 'webcam_port' in camera_config: - camera_config['stream_port'] = camera_config.pop('webcam_port') - if 'webcam_quality' in camera_config: - camera_config['stream_quality'] = camera_config.pop('webcam_quality') - if 'webcam_motion' in camera_config: - camera_config['stream_motion'] = camera_config.pop('webcam_motion') - if 'webcam_maxrate' in camera_config: - camera_config['stream_maxrate'] = camera_config.pop('webcam_maxrate') - if 'webcam_localhost' in camera_config: - camera_config['stream_localhost'] = camera_config.pop('webcam_localhost') - if 'gap' in camera_config: - camera_config['event_gap'] = camera_config.pop('gap') - if 'netcam_http' in camera_config: - camera_config['netcam_keepalive'] = camera_config.pop('netcam_http') in ['1.1', 'keepalive'] - - _get_additional_config(camera_config, camera_id=camera_id) - _set_default_motion_camera(camera_id, camera_config) - - elif utils.remote_camera(camera_config): - pass - - elif utils.simple_mjpeg_camera(camera_config): - _get_additional_config(camera_config, camera_id=camera_id) - - else: # incomplete configuration - logging.warn('camera config file at %s is incomplete, ignoring' % camera_config_path) - - return None - - _camera_config_cache[camera_id] = dict(camera_config) - - return camera_config - - -def set_camera(camera_id, camera_config): - global _camera_config_cache - - camera_config['@id'] = camera_id - _camera_config_cache[camera_id] = camera_config - - camera_config = dict(camera_config) - - if utils.local_motion_camera(camera_config): - old_motion = is_old_motion() - - # adapt directives to old configuration, if needed - if old_motion: - logging.debug('using old motion config directives') - - if 'output_pictures' in camera_config: - camera_config['output_normal'] = camera_config.pop('output_pictures') - if 'emulate_motion' in camera_config: - camera_config['output_all'] = camera_config.pop('emulate_motion') - if 'ffmpeg_output_movies' in camera_config: - camera_config['ffmpeg_cap_new'] = camera_config.pop('ffmpeg_output_movies') - if 'locate_motion_mode' in camera_config: - camera_config['locate'] = camera_config.pop('locate_motion_mode') - if 'picture_filename' in camera_config: - camera_config['jpeg_filename'] = camera_config.pop('picture_filename') - if 'max_movie_time' in camera_config: - camera_config['max_mpeg_time'] = camera_config.pop('max_movie_time') - if 'stream_port' in camera_config: - camera_config['webcam_port'] = camera_config.pop('stream_port') - if 'stream_quality' in camera_config: - camera_config['webcam_quality'] = camera_config.pop('stream_quality') - if 'stream_motion' in camera_config: - camera_config['webcam_motion'] = camera_config.pop('stream_motion') - if 'stream_maxrate' in camera_config: - camera_config['webcam_maxrate'] = camera_config.pop('stream_maxrate') - if 'stream_localhost' in camera_config: - camera_config['webcam_localhost'] = camera_config.pop('stream_localhost') - if 'stream_auth_method' in camera_config: - camera_config.pop('stream_auth_method') - if 'stream_authentication' in camera_config: - camera_config.pop('stream_authentication') - if 'event_gap' in camera_config: - camera_config['gap'] = camera_config.pop('event_gap') - if 'netcam_keepalive' in camera_config: - camera_config['netcam_http'] = '1.1' if camera_config.pop('netcam_keepalive') else '1.0' - - _set_default_motion_camera(camera_id, camera_config, old_motion) - - # set the enabled status in main config - main_config = get_main() - threads = main_config.setdefault('thread', []) - config_file_name = _CAMERA_CONFIG_FILE_NAME % {'id': camera_id} - if camera_config['@enabled'] and config_file_name not in threads: - threads.append(config_file_name) - - elif not camera_config['@enabled']: - threads = [t for t in threads if t != config_file_name] - - main_config['thread'] = threads - - set_main(main_config) - _set_additional_config(camera_config, camera_id=camera_id) - - elif utils.remote_camera(camera_config): - pass - - elif utils.simple_mjpeg_camera(camera_config): - _set_additional_config(camera_config, camera_id=camera_id) - - # read the actual configuration from file - config_file_path = os.path.join(settings.CONF_PATH, _CAMERA_CONFIG_FILE_NAME) % {'id': camera_id} - if os.path.isfile(config_file_path): - lines = get_camera(camera_id, as_lines=True) - - else: - lines = [] - - # write the configuration to file - camera_config_path = os.path.join(settings.CONF_PATH, _CAMERA_CONFIG_FILE_NAME) % {'id': camera_id} - logging.debug('writing camera config to %(path)s...' % {'path': camera_config_path}) - - try: - file = open(camera_config_path, 'w') - - except Exception as e: - logging.error('could not open camera config file %(path)s for writing: %(msg)s' % { - 'path': camera_config_path, 'msg': unicode(e)}) - - raise - - lines = _dict_to_conf(lines, camera_config) - - try: - file.writelines([utils.make_str(l) + '\n' for l in lines]) - - except Exception as e: - logging.error('could not write camera config file %(path)s: %(msg)s' % { - 'path': camera_config_path, 'msg': unicode(e)}) - - raise - - finally: - file.close() - - -def add_camera(device_details): - global _camera_ids_cache - global _camera_config_cache - - proto = device_details['proto'] - if proto in ['netcam', 'mjpeg']: - host = device_details['host'] - if device_details['port']: - host += ':' + str(device_details['port']) - - if device_details['username'] and proto == 'mjpeg': - if device_details['password']: - host = device_details['username'] + ':' + device_details['password'] + '@' + host - - else: - host = device_details['username'] + '@' + host - - device_details['url'] = urlparse.urlunparse((device_details['scheme'], host, device_details['uri'], '', '', '')) - - # determine the last camera id - camera_ids = get_camera_ids() - - camera_id = 1 - while camera_id in camera_ids: - camera_id += 1 - - logging.info('adding new camera with id %(id)s...' % {'id': camera_id}) - - # prepare a default camera config - camera_config = {'@enabled': True} - if proto == 'v4l2': - # find a suitable resolution - for (w, h) in v4l2ctl.list_resolutions(device_details['uri']): - if w > 300: - camera_config['width'] = w - camera_config['height'] = h - break - - camera_config['videodevice'] = device_details['uri'] - _set_default_motion_camera(camera_id, camera_config) - - elif proto == 'motioneye': - camera_config['@proto'] = 'motioneye' - camera_config['@scheme'] = device_details['scheme'] - camera_config['@host'] = device_details['host'] - camera_config['@port'] = device_details['port'] - camera_config['@uri'] = device_details['uri'] - camera_config['@username'] = device_details['username'] - camera_config['@password'] = device_details['password'] - camera_config['@remote_camera_id'] = device_details['remote_camera_id'] - - 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'] - - camera_config['netcam_keepalive'] = device_details.get('keep_alive') - camera_config['netcam_tolerant_check'] = True - - 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 - camera_config['@proto'] = 'mjpeg' - camera_config['@url'] = device_details['url'] - _set_default_simple_mjpeg_camera(camera_id, camera_config) - - # write the configuration to file - set_camera(camera_id, camera_config) - - _camera_ids_cache = None - _camera_config_cache = {} - - camera_config = get_camera(camera_id) - - return camera_config - - -def rem_camera(camera_id): - global _camera_ids_cache - global _camera_config_cache - - camera_config_name = _CAMERA_CONFIG_FILE_NAME % {'id': camera_id} - camera_config_path = os.path.join(settings.CONF_PATH, _CAMERA_CONFIG_FILE_NAME) % {'id': camera_id} - - # remove the camera from the main config - main_config = get_main() - threads = main_config.setdefault('thread', []) - threads = [t for t in threads if t != camera_config_name] - - main_config['thread'] = threads - - set_main(main_config) - - logging.info('removing camera config file %(path)s...' % {'path': camera_config_path}) - - _camera_ids_cache = None - _camera_config_cache = {} - - try: - os.remove(camera_config_path) - - except Exception as e: - logging.error('could not remove camera config file %(path)s: %(msg)s' % { - 'path': camera_config_path, 'msg': unicode(e)}) - - raise - - -def main_ui_to_dict(ui): - data = { - '@enabled': ui['enabled'], - - '@show_advanced': ui['show_advanced'], - '@admin_username': ui['admin_username'], - '@admin_password': ui['admin_password'], - '@normal_username': ui['normal_username'], - '@normal_password': ui['normal_password'] - } - - # additional configs - for name, value in ui.iteritems(): - if not name.startswith('_'): - continue - - data['@' + name] = value - - return data - - -def main_dict_to_ui(data): - ui = { - 'enabled': data['@enabled'], - - 'show_advanced': data['@show_advanced'], - 'admin_username': data['@admin_username'], - 'admin_password': data['@admin_password'], - 'normal_username': data['@normal_username'], - 'normal_password': data['@normal_password'] - } - - # additional configs - for name, value in data.iteritems(): - if not name.startswith('@_'): - continue - - ui[name[1:]] = value - - return ui - - -def motion_camera_ui_to_dict(ui, old_config=None): - import smbctl - - old_config = dict(old_config or {}) - main_config = get_main() # needed for surveillance password - - data = { - # device - '@name': ui['name'], - '@enabled': ui['enabled'], - 'lightswitch': int(ui['light_switch_detect']) * 50, - 'auto_brightness': ui['auto_brightness'], - 'framerate': int(ui['framerate']), - 'rotate': int(ui['rotation']), - - # file storage - '@storage_device': ui['storage_device'], - '@network_server': ui['network_server'], - '@network_share_name': ui['network_share_name'], - '@network_username': ui['network_username'], - '@network_password': ui['network_password'], - - # text overlay - 'text_left': '', - 'text_right': '', - 'text_double': False, - - # streaming - 'stream_localhost': not ui['video_streaming'], - 'stream_port': int(ui['streaming_port']), - 'stream_maxrate': int(ui['streaming_framerate']), - 'stream_quality': max(1, int(ui['streaming_quality'])), - '@webcam_resolution': max(1, int(ui['streaming_resolution'])), - '@webcam_server_resize': ui['streaming_server_resize'], - 'stream_motion': ui['streaming_motion'], - 'stream_auth_method': {'disabled': 0, 'basic': 1, 'digest': 2}.get(ui['streaming_auth_mode'], 0), - 'stream_authentication': main_config['@normal_username'] + ':' + main_config['@normal_password'], - - # still images - 'output_pictures': False, - 'emulate_motion': False, - 'snapshot_interval': 0, - 'picture_filename': '', - 'snapshot_filename': '', - '@preserve_pictures': int(ui['preserve_pictures']), - - # motion detection - '@motion_detection': ui['motion_detection'], - 'text_changes': ui['show_frame_changes'], - 'locate_motion_mode': ui['show_frame_changes'], - 'noise_tune': ui['auto_noise_detect'], - 'noise_level': max(1, int(round(int(ui['noise_level']) * 2.55))), - 'event_gap': int(ui['event_gap']), - 'pre_capture': int(ui['pre_capture']), - 'post_capture': int(ui['post_capture']), - 'minimum_motion_frames': int(ui['minimum_motion_frames']), - - # movies - 'ffmpeg_output_movies': ui['motion_movies'], - 'movie_filename': ui['movie_file_name'], - 'ffmpeg_bps': 44000, # a quality of about 85% for 320x240x2fps - 'max_movie_time': ui['max_movie_length'], - '@preserve_movies': int(ui['preserve_movies']), - - # working schedule - '@working_schedule': '', - - # events - 'on_event_start': '', - 'on_event_end': '' - } - - if utils.v4l2_camera(old_config): - proto = 'v4l2' - - else: - proto = 'netcam' - - if proto == 'v4l2': - # leave videodevice unchanged - - # resolution - if not ui['resolution']: - ui['resolution'] = '320x240' - - 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) - - if 'brightness' in ui: - if int(ui['brightness']) == 50: - data['brightness'] = 0 - - else: - data['brightness'] = max(1, int(round(int(ui['brightness']) * 2.55))) - - if 'contrast' in ui: - if int(ui['contrast']) == 50: - data['contrast'] = 0 - - else: - data['contrast'] = max(1, int(round(int(ui['contrast']) * 2.55))) - - if 'saturation' in ui: - if int(ui['saturation']) == 50: - data['saturation'] = 0 - - else: - data['saturation'] = max(1, int(round(int(ui['saturation']) * 2.55))) - - if 'hue' in ui: - if int(ui['hue']) == 50: - data['hue'] = 0 - - else: - data['hue'] = max(1, int(round(int(ui['hue']) * 2.55))) - - else: # assuming netcam - 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 - - if (ui['storage_device'] == 'network-share') and settings.SMB_SHARES: - mount_point = smbctl.make_mount_point(ui['network_server'], ui['network_share_name'], ui['network_username']) - if ui['root_directory'].startswith('/'): - ui['root_directory'] = ui['root_directory'][1:] - data['target_dir'] = os.path.normpath(os.path.join(mount_point, ui['root_directory'])) - - elif ui['storage_device'].startswith('local-disk'): - target_dev = ui['storage_device'][10:].replace('-', '/') - mounted_partitions = diskctl.list_mounted_partitions() - partition = mounted_partitions[target_dev] - mount_point = partition['mount_point'] - - if ui['root_directory'].startswith('/'): - ui['root_directory'] = ui['root_directory'][1:] - data['target_dir'] = os.path.normpath(os.path.join(mount_point, ui['root_directory'])) - - else: - data['target_dir'] = ui['root_directory'] - - if ui['text_overlay']: - left_text = ui['left_text'] - if left_text == 'camera-name': - data['text_left'] = ui['name'] - - elif left_text == 'timestamp': - data['text_left'] = '%Y-%m-%d\\n%T' - - elif left_text == 'disabled': - data['text_left'] = '' - - else: - data['text_left'] = ui['custom_left_text'] - - right_text = ui['right_text'] - if right_text == 'camera-name': - data['text_right'] = ui['name'] - - elif right_text == 'timestamp': - data['text_right'] = '%Y-%m-%d\\n%T' - - elif right_text == 'disabled': - data['text_right'] = '' - - else: - data['text_right'] = ui['custom_right_text'] - - if proto == 'netcam' or data['width'] > 320: - data['text_double'] = True - - if ui['still_images']: - capture_mode = ui['capture_mode'] - if capture_mode == 'motion-triggered': - data['output_pictures'] = True - data['picture_filename'] = ui['image_file_name'] - - elif capture_mode == 'interval-snapshots': - data['snapshot_interval'] = int(ui['snapshot_interval']) - data['snapshot_filename'] = ui['image_file_name'] - - elif capture_mode == 'all-frames': - data['output_pictures'] = True - data['emulate_motion'] = True - data['picture_filename'] = ui['image_file_name'] - - data['quality'] = max(1, int(ui['image_quality'])) - - if proto == 'v4l2': - max_val = data['width'] * data['height'] * data['framerate'] / 3 - - else: # always assume a netcam image size of 640x480, since we have no means to know it at this point - max_val = 640 * 480 * data['framerate'] / 3 - - max_val = min(max_val, 9999999) - - data['ffmpeg_bps'] = int(ui['movie_quality']) * max_val / 100 - - # working schedule - if ui['working_schedule']: - data['@working_schedule'] = ( - ui['monday_from'] + '-' + ui['monday_to'] + '|' + - ui['tuesday_from'] + '-' + ui['tuesday_to'] + '|' + - ui['wednesday_from'] + '-' + ui['wednesday_to'] + '|' + - ui['thursday_from'] + '-' + ui['thursday_to'] + '|' + - ui['friday_from'] + '-' + ui['friday_to'] + '|' + - ui['saturday_from'] + '-' + ui['saturday_to'] + '|' + - ui['sunday_from'] + '-' + ui['sunday_to']) - - data['@working_schedule_type'] = ui['working_schedule_type'] - - # event start - event_relay_path = os.path.join(settings.PROJECT_PATH, 'eventrelay.py') - event_relay_path = os.path.abspath(event_relay_path) - - on_event_start = ['%(script)s start %%t' % {'script': event_relay_path}] - if ui['email_notifications_enabled']: - send_mail_path = os.path.join(settings.PROJECT_PATH, 'sendmail.py') - send_mail_path = os.path.abspath(send_mail_path) - emails = re.sub('\\s', '', ui['email_notifications_addresses']) - - on_event_start.append("%(script)s '%(server)s' '%(port)s' '%(account)s' '%(password)s' '%(tls)s' '%(to)s' 'motion_start' '%%t' '%%Y-%%m-%%dT%%H:%%M:%%S' '%(timespan)s'" % { - 'script': send_mail_path, - 'server': ui['email_notifications_smtp_server'], - 'port': ui['email_notifications_smtp_port'], - 'account': ui['email_notifications_smtp_account'], - 'password': ui['email_notifications_smtp_password'], - 'tls': ui['email_notifications_smtp_tls'], - 'to': emails, - 'timespan': ui['email_notifications_picture_time_span']}) - - if ui['web_hook_notifications_enabled']: - web_hook_path = os.path.join(settings.PROJECT_PATH, 'webhook.py') - web_hook_path = os.path.abspath(web_hook_path) - url = re.sub('\\s', '+', ui['web_hook_notifications_url']) - - on_event_start.append("%(script)s '%(method)s' '%(url)s'" % { - 'script': web_hook_path, - 'method': ui['web_hook_notifications_http_method'], - 'url': url}) - - if ui['command_notifications_enabled']: - commands = ui['command_notifications_exec'].split(';') - on_event_start += [c.strip() for c in commands] - - data['on_event_start'] = '; '.join(on_event_start) - - # event end - on_event_end = ['%(script)s stop %%t' % {'script': event_relay_path}] - - data['on_event_end'] = '; '.join(on_event_end) - - # additional configs - for name, value in ui.iteritems(): - if not name.startswith('_'): - continue - - data['@' + name] = value - - # extra motion options - for name in old_config.keys(): - if name not in _KNOWN_MOTION_OPTIONS and not name.startswith('@'): - old_config.pop(name) - - extra_options = ui.get('extra_options', []) - for name, value in extra_options: - data[name] = value or '' - - old_config.update(data) - - return old_config - - -def motion_camera_dict_to_ui(data): - import smbctl - - ui = { - # device - 'name': data['@name'], - 'enabled': data['@enabled'], - 'id': data['@id'], - 'light_switch_detect': data['lightswitch'] > 0, - 'auto_brightness': data['auto_brightness'], - 'framerate': int(data['framerate']), - 'rotation': int(data['rotate']), - - # file storage - 'smb_shares': settings.SMB_SHARES, - 'storage_device': data['@storage_device'], - 'network_server': data['@network_server'], - 'network_share_name': data['@network_share_name'], - 'network_username': data['@network_username'], - 'network_password': data['@network_password'], - 'disk_used': 0, - 'disk_total': 0, - 'available_disks': diskctl.list_mounted_disks(), - - # text overlay - 'text_overlay': False, - 'left_text': 'camera-name', - 'right_text': 'timestamp', - 'custom_left_text': '', - 'custom_right_text': '', - - # streaming - 'video_streaming': not data['stream_localhost'], - 'streaming_framerate': int(data['stream_maxrate']), - 'streaming_quality': int(data['stream_quality']), - 'streaming_resolution': int(data['@webcam_resolution']), - 'streaming_server_resize': data['@webcam_server_resize'], - 'streaming_port': int(data['stream_port']), - 'streaming_auth_mode': {0: 'disabled', 1: 'basic', 2: 'digest'}.get(data.get('stream_auth_method'), 'disabled'), - 'streaming_motion': int(data['stream_motion']), - - # still images - 'still_images': False, - 'capture_mode': 'motion-triggered', - 'image_file_name': '%Y-%m-%d/%H-%M-%S', - 'image_quality': 85, - 'snapshot_interval': 0, - 'preserve_pictures': data['@preserve_pictures'], - - # motion detection - 'motion_detection': data['@motion_detection'], - 'show_frame_changes': data['text_changes'] or data['locate_motion_mode'], - 'auto_noise_detect': data['noise_tune'], - 'noise_level': int(int(data['noise_level']) / 2.55), - 'event_gap': int(data['event_gap']), - 'pre_capture': int(data['pre_capture']), - 'post_capture': int(data['post_capture']), - 'minimum_motion_frames': int(data['minimum_motion_frames']), - - # motion movies - 'motion_movies': data['ffmpeg_output_movies'], - 'movie_file_name': data['movie_filename'], - 'max_movie_length': data['max_movie_time'], - 'preserve_movies': data['@preserve_movies'], - - # motion notifications - 'email_notifications_enabled': False, - 'web_hook_notifications_enabled': False, - 'command_notifications_enabled': False, - - # working schedule - 'working_schedule': False, - 'working_schedule_type': 'during', - 'monday_from': '', 'monday_to': '', - 'tuesday_from': '', 'tuesday_to': '', - 'wednesday_from': '', 'wednesday_to': '', - 'thursday_from': '', 'thursday_to': '', - 'friday_from': '', 'friday_to': '', - 'saturday_from': '', 'saturday_to': '', - 'sunday_from': '', 'sunday_to': '' - } - - if utils.net_camera(data): - ui['device_url'] = data['netcam_url'] - ui['proto'] = 'netcam' - - # 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' - - # resolutions - resolutions = v4l2ctl.list_resolutions(data['videodevice']) - ui['available_resolutions'] = [(str(w) + 'x' + str(h)) for (w, h) in resolutions] - ui['resolution'] = str(data['width']) + 'x' + str(data['height']) - - # the brightness & co. keys in the ui dictionary - # indicate the presence of these controls - # we must call v4l2ctl functions to determine the available controls - brightness = v4l2ctl.get_brightness(data['videodevice']) - if brightness is not None: # has brightness control - if data.get('brightness', 0) != 0: - ui['brightness'] = brightness - - else: - ui['brightness'] = 50 - - contrast = v4l2ctl.get_contrast(data['videodevice']) - if contrast is not None: # has contrast control - if data.get('contrast', 0) != 0: - ui['contrast'] = contrast - - else: - ui['contrast'] = 50 - - saturation = v4l2ctl.get_saturation(data['videodevice']) - if saturation is not None: # has saturation control - if data.get('saturation', 0) != 0: - ui['saturation'] = saturation - - else: - ui['saturation'] = 50 - - hue = v4l2ctl.get_hue(data['videodevice']) - if hue is not None: # has hue control - if data.get('hue', 0) != 0: - ui['hue'] = hue - - else: - ui['hue'] = 50 - - threshold = data['threshold'] * 100.0 / (data['width'] * data['height']) - - ui['frame_change_threshold'] = threshold - - if (data['@storage_device'] == 'network-share') and settings.SMB_SHARES: - mount_point = smbctl.make_mount_point(data['@network_server'], data['@network_share_name'], data['@network_username']) - ui['root_directory'] = data['target_dir'][len(mount_point):] or '/' - - elif data['@storage_device'].startswith('local-disk'): - target_dev = data['@storage_device'][10:].replace('-', '/') - mounted_partitions = diskctl.list_mounted_partitions() - for partition in mounted_partitions.values(): - if partition['target'] == target_dev and data['target_dir'].startswith(partition['mount_point']): - ui['root_directory'] = data['target_dir'][len(partition['mount_point']):] or '/' - break - - else: # not found for some reason - logging.error('could not find mounted partition for device "%s" and target dir "%s"' % (target_dev, data['target_dir'])) - ui['root_directory'] = data['target_dir'] - - else: - ui['root_directory'] = data['target_dir'] - - # disk usage - usage = utils.get_disk_usage(data['target_dir']) - if usage: - ui['disk_used'], ui['disk_total'] = usage - - text_left = data['text_left'] - text_right = data['text_right'] - if text_left or text_right: - ui['text_overlay'] = True - - if text_left == data['@name']: - ui['left_text'] = 'camera-name' - - elif text_left == '%Y-%m-%d\\n%T': - ui['left_text'] = 'timestamp' - - elif text_left == '': - ui['left_text'] = 'disabled' - - else: - ui['left_text'] = 'custom-text' - ui['custom_left_text'] = text_left - - if text_right == data['@name']: - ui['right_text'] = 'camera-name' - - elif text_right == '%Y-%m-%d\\n%T': - ui['right_text'] = 'timestamp' - - elif text_right == '': - ui['right_text'] = 'disabled' - - else: - ui['right_text'] = 'custom-text' - ui['custom_right_text'] = text_right - - emulate_motion = data['emulate_motion'] - output_pictures = data['output_pictures'] - picture_filename = data['picture_filename'] - snapshot_interval = data['snapshot_interval'] - snapshot_filename = data['snapshot_filename'] - - if (((emulate_motion or output_pictures) and picture_filename) or - (snapshot_interval and snapshot_filename)): - - ui['still_images'] = True - - if emulate_motion: - ui['capture_mode'] = 'all-frames' - ui['image_file_name'] = picture_filename - - elif snapshot_interval: - ui['capture_mode'] = 'interval-snapshots' - ui['image_file_name'] = snapshot_filename - ui['snapshot_interval'] = snapshot_interval - - elif output_pictures: - ui['capture_mode'] = 'motion-triggered' - ui['image_file_name'] = picture_filename - - ui['image_quality'] = data['quality'] - - ffmpeg_bps = data['ffmpeg_bps'] - if ffmpeg_bps is not None: - if utils.v4l2_camera(data): - max_val = data['width'] * data['height'] * data['framerate'] / 3 - - else: # net camera - max_val = 640 * 480 * data['framerate'] / 3 - - max_val = min(max_val, 9999999) - - ui['movie_quality'] = min(100, int(round(ffmpeg_bps * 100.0 / max_val))) - - # working schedule - working_schedule = data['@working_schedule'] - if working_schedule: - days = working_schedule.split('|') - ui['working_schedule'] = True - ui['monday_from'], ui['monday_to'] = days[0].split('-') - ui['tuesday_from'], ui['tuesday_to'] = days[1].split('-') - ui['wednesday_from'], ui['wednesday_to'] = days[2].split('-') - ui['thursday_from'], ui['thursday_to'] = days[3].split('-') - ui['friday_from'], ui['friday_to'] = days[4].split('-') - ui['saturday_from'], ui['saturday_to'] = days[5].split('-') - ui['sunday_from'], ui['sunday_to'] = days[6].split('-') - ui['working_schedule_type'] = data['@working_schedule_type'] - - # event start - on_event_start = data.get('on_event_start') or [] - if on_event_start: - on_event_start = [e.strip() for e in on_event_start.split(';')] - - ui['email_notifications_picture_time_span'] = 0 - command_notifications = [] - for e in on_event_start: - if e.count('sendmail.py') and e.count('motion_start'): - e = shlex.split(e) - if len(e) < 10: - continue - - ui['email_notifications_enabled'] = True - ui['email_notifications_smtp_server'] = e[1] - ui['email_notifications_smtp_port'] = e[2] - ui['email_notifications_smtp_account'] = e[3] - ui['email_notifications_smtp_password'] = e[4] - ui['email_notifications_smtp_tls'] = e[5].lower() == 'true' - ui['email_notifications_addresses'] = e[6] - try: - ui['email_notifications_picture_time_span'] = int(e[10]) - - except: - ui['email_notifications_picture_time_span'] = 0 - - elif e.count('webhook.py'): - e = shlex.split(e) - if len(e) != 3: - continue - - ui['web_hook_notifications_enabled'] = True - ui['web_hook_notifications_http_method'] = e[1] - ui['web_hook_notifications_url'] = e[2] - - elif e.count('eventrelay.py'): - continue # ignore internal relay script - - else: # custom command - command_notifications.append(e) - - if command_notifications: - ui['command_notifications_enabled'] = True - ui['command_notifications_exec'] = '; '.join(command_notifications) - - # additional configs - for name, value in data.iteritems(): - if not name.startswith('@_'): - continue - - ui[name[1:]] = value - - # extra motion options - extra_options = [] - for name, value in data.iteritems(): - if name not in _KNOWN_MOTION_OPTIONS and not name.startswith('@'): - extra_options.append((name, value)) - - ui['extra_options'] = extra_options - - return ui - - -def simple_mjpeg_camera_ui_to_dict(ui, old_config=None): - old_config = dict(old_config or {}) - - data = { - # device - '@name': ui['name'], - '@enabled': ui['enabled'], - } - - # additional configs - for name, value in ui.iteritems(): - if not name.startswith('_'): - continue - - data['@' + name] = value - - old_config.update(data) - - return old_config - - -def simple_mjpeg_camera_dict_to_ui(data): - ui = { - 'name': data['@name'], - 'enabled': data['@enabled'], - 'id': data['@id'], - 'proto': 'mjpeg', - 'url': data['@url'] - } - - # additional configs - for name, value in data.iteritems(): - if not name.startswith('@_'): - continue - - ui[name[1:]] = value - - return ui - - -def backup(): - logging.debug('generating config backup file') - - if len(os.listdir(settings.CONF_PATH)) > 100: - logging.debug('config path "%s" appears to be a system-wide config directory, performing a selective backup' % settings.CONF_PATH) - cmd = 'cd "%s" && tar zc motion.conf thread-*.conf' % settings.CONF_PATH - try: - content = subprocess.check_output(cmd, shell=True) - logging.debug('backup file created (%s bytes)' % len(content)) - - return content - - except Exception as e: - logging.error('backup failed: %s' % e, exc_info=True) - - return None - - else: - logging.debug('config path "%s" appears to be a motion-specific config directory, performing a full backup' % settings.CONF_PATH) - - cmd = 'cd "%s" && tar zc .' % settings.CONF_PATH - try: - content = subprocess.check_output(cmd, shell=True) - logging.debug('backup file created (%s bytes)' % len(content)) - - return content - - except Exception as e: - logging.error('backup failed: %s' % e, exc_info=True) - - return None - - -def restore(content): - global _main_config_cache - global _camera_config_cache - global _camera_ids_cache - global _additional_structure_cache - - logging.info('restoring config from backup file') - - cmd = 'tar zxC "%s" || true' % settings.CONF_PATH - - try: - p = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - msg = p.communicate(content)[0] - if msg: - logging.error('failed to restore configuration: %s' % msg) - return False - - logging.debug('configuration restored successfully') - - if settings.ENABLE_REBOOT: - def later(): - powerctl.reboot() - - IOLoop.instance().add_timeout(datetime.timedelta(seconds=2), later) - - else: - logging.info('invalidating config cache') - invalidate() - - return {'reboot': settings.ENABLE_REBOOT} - - except Exception as e: - logging.error('failed to restore configuration: %s' % e, exc_info=True) - - return None - - -def is_old_motion(): - import motionctl - - try: - binary, version = motionctl.find_motion() # @UnusedVariable - - if version.startswith('trunkREV'): # e.g. trunkREV599 - version = int(version[8:]) - 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 - - else: # stable release, should be in the format x.y.z - return update.compare_versions(version, _LAST_OLD_CONFIG_VERSIONS[1]) <= 0 - - except: - 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 - global _camera_ids_cache - global _additional_structure_cache - - logging.debug('invalidating config cache') - _main_config_cache = None - _camera_config_cache = {} - _camera_ids_cache = None - _additional_structure_cache = {} - - -def _value_to_python(value): - value_lower = value.lower() - if value_lower == 'off': - return False - - elif value_lower == 'on': - return True - - try: - return int(value) - - except ValueError: - try: - return float(value) - - except ValueError: - return value - - -def _python_to_value(value): - if value is True: - return 'on' - - elif value is False: - return 'off' - - elif isinstance(value, (int, float)): - return str(value) - - else: - return value - - -def _conf_to_dict(lines, list_names=[], no_convert=[]): - data = OrderedDict() - - for line in lines: - line = line.strip() - if len(line) == 0: # empty line - continue - - if line.startswith(';'): # comment line - continue - - match = re.match('^\#\s*(\@\w+)\s*(.*)', line) - if match: - name, value = match.groups()[:2] - - elif line.startswith('#') or line.startswith(';'): # comment line - continue - - else: - parts = line.split(None, 1) - if len(parts) == 1: # empty value - parts.append('') - - (name, value) = parts - value = value.strip() - - if name not in no_convert: - value = _value_to_python(value) - - if name in list_names: - data.setdefault(name, []).append(value) - - else: - data[name] = value - - return data - - -def _dict_to_conf(lines, data, list_names=[]): - conf_lines = [] - remaining = OrderedDict(data) - processed = set() - - # parse existing lines and replace the values - - for line in lines: - line = line.strip() - if len(line) == 0: # empty line - conf_lines.append(line) - continue - - if line.startswith(';'): # simple comment line - conf_lines.append(line) - continue - - match = re.match('^\#\s*(\@\w+)\s*(.*)', line) - if match: # @line - (name, value) = match.groups()[:2] - - elif line.startswith('#'): # simple comment line - conf_lines.append(line) - continue - - else: - parts = line.split(None, 1) - if len(parts) == 2: - (name, value) = parts - - else: - (name, value) = parts[0], '' - - if name in processed: - continue # name already processed - - processed.add(name) - - if name in list_names: - new_value = data.get(name) - if new_value is not None: - for v in new_value: - if v is None: - continue - - line = name + ' ' + _python_to_value(v) - conf_lines.append(line) - - else: - line = name + ' ' + value - conf_lines.append(line) - - else: - new_value = data.get(name) - if new_value is not None: - value = _python_to_value(new_value) - line = name + ' ' + value - conf_lines.append(line) - - remaining.pop(name, None) - - # add the remaining config values not covered by existing lines - - if len(remaining) and len(lines): - conf_lines.append('') # add a blank line - - for (name, value) in remaining.iteritems(): - if name.startswith('@_'): - continue # ignore additional configs - - if name in list_names: - for v in value: - if v is None: - continue - - line = name + ' ' + _python_to_value(v) - conf_lines.append(line) - - else: - line = name + ' ' + _python_to_value(value) - conf_lines.append(line) - - # build the final config lines - conf_lines.sort(key=lambda l: not l.startswith('@')) - - lines = [] - for i, line in enumerate(conf_lines): - # squeeze successive blank lines - if i > 0 and len(line.strip()) == 0 and len(conf_lines[i - 1].strip()) == 0: - continue - - if line.startswith('@'): - line = '# ' + line - - elif i > 0 and conf_lines[i - 1].startswith('@'): - lines.append('') # add a blank line between @lines and the rest - - lines.append(line) - - return lines - - -def _set_default_motion(data, old_motion): - data.setdefault('@enabled', True) - - data.setdefault('@show_advanced', False) - data.setdefault('@admin_username', 'admin') - data.setdefault('@admin_password', '') - data.setdefault('@normal_username', 'user') - data.setdefault('@normal_password', '') - - if old_motion: - data.setdefault('control_port', 7999) - - else: - data.setdefault('webcontrol_port', 7999) - - -def _set_default_motion_camera(camera_id, data, old_motion=False): - data.setdefault('@name', 'Camera' + str(camera_id)) - data.setdefault('@enabled', False) - data.setdefault('@id', camera_id) - - if not utils.net_camera(data): - data.setdefault('videodevice', '/dev/video0') - data.setdefault('brightness', 0) - data.setdefault('contrast', 0) - data.setdefault('saturation', 0) - data.setdefault('hue', 0) - data.setdefault('width', 352) - data.setdefault('height', 288) - - data.setdefault('lightswitch', 50) - data.setdefault('auto_brightness', False) - data.setdefault('framerate', 2) - data.setdefault('rotate', 0) - - data.setdefault('@storage_device', 'custom-path') - data.setdefault('@network_server', '') - data.setdefault('@network_share_name', '') - data.setdefault('@network_username', '') - data.setdefault('@network_password', '') - data.setdefault('target_dir', settings.MEDIA_PATH) - - if old_motion: - data.setdefault('webcam_localhost', False) - data.setdefault('webcam_port', int('808' + str(camera_id))) - data.setdefault('webcam_maxrate', 5) - data.setdefault('webcam_quality', 85) - data.setdefault('webcam_motion', False) - - else: - data.setdefault('stream_localhost', False) - data.setdefault('stream_port', int('808' + str(camera_id))) - data.setdefault('stream_maxrate', 5) - data.setdefault('stream_quality', 85) - data.setdefault('stream_motion', False) - data.setdefault('stream_auth_method', 0) - - data.setdefault('@webcam_resolution', 100) - data.setdefault('@webcam_server_resize', False) - - data.setdefault('text_left', data['@name']) - data.setdefault('text_right', '%Y-%m-%d\\n%T') - data.setdefault('text_double', False) - - data.setdefault('@motion_detection', True) - data.setdefault('text_changes', False) - if old_motion: - data.setdefault('locate', False) - - else: - data.setdefault('locate_motion_mode', False) - data.setdefault('locate_motion_style', 'redbox') - - data.setdefault('threshold', 2000) - data.setdefault('noise_tune', True) - data.setdefault('noise_level', 32) - data.setdefault('minimum_motion_frames', 1) - - data.setdefault('pre_capture', 2) - data.setdefault('post_capture', 4) - data.setdefault('minimum_motion_frames', 1) - - if old_motion: - data.setdefault('output_normal', False) - data.setdefault('jpeg_filename', '') - data.setdefault('output_all', False) - data.setdefault('gap', 10) - - else: - data.setdefault('output_pictures', False) - data.setdefault('picture_filename', '') - data.setdefault('emulate_motion', False) - data.setdefault('event_gap', 10) - - data.setdefault('snapshot_interval', 0) - data.setdefault('snapshot_filename', '') - data.setdefault('quality', 85) - data.setdefault('@preserve_pictures', 0) - - data.setdefault('ffmpeg_variable_bitrate', 0) - data.setdefault('ffmpeg_bps', 44000) # a quality of about 85% - data.setdefault('movie_filename', '%Y-%m-%d/%H-%M-%S') - if old_motion: - data.setdefault('max_mpeg_time', 0) - data.setdefault('ffmpeg_cap_new', False) - - else: - data.setdefault('max_movie_time', 0) - data.setdefault('ffmpeg_output_movies', False) - data.setdefault('ffmpeg_video_codec', 'msmpeg4') - data.setdefault('@preserve_movies', 0) - - data.setdefault('@working_schedule', '') - data.setdefault('@working_schedule_type', 'outside') - - data.setdefault('on_event_start', '') - data.setdefault('on_event_end', '') - - -def _set_default_simple_mjpeg_camera(camera_id, data): - data.setdefault('@name', 'Camera' + str(camera_id)) - data.setdefault('@enabled', False) - data.setdefault('@id', camera_id) - - -def get_additional_structure(camera, separators=False): - if _additional_structure_cache.get((camera, separators)) is None: - logging.debug('loading additional config structure for %s, %s separators' % ( - 'camera' if camera else 'main', - 'with' if separators else 'without')) - - # gather sections - sections = OrderedDict() - for func in _additional_section_funcs: - result = func() - if not result: - continue - - if result.get('reboot') and not settings.ENABLE_REBOOT: - continue - - if bool(result.get('camera')) != bool(camera): - continue - - result['name'] = func.func_name - sections[func.func_name] = result - - logging.debug('additional config section: %s' % result['name']) - - configs = OrderedDict() - for func in _additional_config_funcs: - result = func() - if not result: - continue - - if result.get('reboot') and not settings.ENABLE_REBOOT: - continue - - if bool(result.get('camera')) != bool(camera): - continue - - if result['type'] == 'separator' and not separators: - continue - - result['name'] = func.func_name - configs[func.func_name] = result - - section = sections.setdefault(result.get('section'), {}) - section.setdefault('configs', []).append(result) - - logging.debug('additional config item: %s' % result['name']) - - _additional_structure_cache[(camera, separators)] = sections, configs - - return _additional_structure_cache[(camera, separators)] - - -def _get_additional_config(data, camera_id=None): - args = [camera_id] if camera_id else [] - - (sections, configs) = get_additional_structure(camera=bool(camera_id)) - get_funcs = set([c.get('get') for c in configs.itervalues() if c.get('get')]) - get_func_values = collections.OrderedDict((f, f(*args)) for f in get_funcs) - - for name, section in sections.iteritems(): - if not section.get('get'): - continue - - if section.get('get_set_dict'): - data['@_' + name] = get_func_values.get(section['get'], {}).get(name) - - else: - data['@_' + name] = get_func_values.get(section['get']) - - for name, config in configs.iteritems(): - if not config.get('get'): - continue - - if config.get('get_set_dict'): - data['@_' + name] = get_func_values.get(config['get'], {}).get(name) - - else: - data['@_' + name] = get_func_values.get(config['get']) - - -def _set_additional_config(data, camera_id=None): - args = [camera_id] if camera_id else [] - - (sections, configs) = get_additional_structure(camera=bool(camera_id)) - - set_func_values = collections.OrderedDict() - for name, section in sections.iteritems(): - if not section.get('set'): - continue - - if ('@_' + name) not in data: - continue - - if section.get('get_set_dict'): - set_func_values.setdefault(section['set'], {})[name] = data['@_' + name] - - else: - set_func_values[section['set']] = data['@_' + name] - - for name, config in configs.iteritems(): - if not config.get('set'): - continue - - if ('@_' + name) not in data: - continue - - if config.get('get_set_dict'): - set_func_values.setdefault(config['set'], {})[name] = data['@_' + name] - - else: - set_func_values[config['set']] = data['@_' + name] - - for func, value in set_func_values.iteritems(): - func(*(args + [value])) diff --git a/src/diskctl.py b/src/diskctl.py deleted file mode 100644 index b4cd738..0000000 --- a/src/diskctl.py +++ /dev/null @@ -1,259 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging -import os -import re -import subprocess - - -def _list_mounts(): - logging.debug('listing mounts...') - - seen_targets = set() - - mounts = [] - with open('/proc/mounts', 'r') as f: - for line in f: - line = line.strip() - if not line: - continue - parts = line.split() - if len(parts) < 4: - continue - - target = parts[0] - mount_point = parts[1] - fstype = parts[2] - opts = parts[3] - - if not os.access(mount_point, os.W_OK): - continue - - if target in seen_targets: - continue # probably a bind mount - - seen_targets.add(target) - - if fstype == 'fuseblk': - fstype = 'ntfs' # most likely - - logging.debug('found mount "%s" at "%s"' % (target, mount_point)) - - mounts.append({ - 'target': target, - 'mount_point': mount_point, - 'fstype': fstype, - 'opts': opts, - }) - - return mounts - - -def _list_disks(): - if os.path.exists('/dev/disk/by-id/'): - return _list_disks_dev_by_id() - - else: # fall back to fdisk -l - return _list_disks_fdisk() - - -def _list_disks_dev_by_id(): - logging.debug('listing disks using /dev/disk/by-id/') - - disks_by_dev = {} - partitions_by_dev = {} - - for entry in os.listdir('/dev/disk/by-id/'): - parts = entry.split('-', 1) - if len(parts) < 2: - continue - - target = os.path.realpath(os.path.join('/dev/disk/by-id/', entry)) - - bus, entry = parts - m = re.search('-part(\d+)$', entry) - if m: - part_no = int(m.group(1)) - entry = re.sub('-part\d+$', '', entry) - - else: - part_no = None - - parts = entry.split('_') - if len(parts) < 2: - vendor = parts[0] - model = '' - - else: - vendor, model = parts[:2] - - if part_no is not None: - logging.debug('found partition "%s" at "%s" on bus "%s": "%s %s"' % (part_no, target, bus, vendor, model)) - - partitions_by_dev[target] = { - 'target': target, - 'bus': bus, - 'vendor': vendor, - 'model': model, - 'part_no': part_no, - 'unmatched': True - } - - else: - logging.debug('found disk at "%s" on bus "%s": "%s %s"' % (target, bus, vendor, model)) - - disks_by_dev[target] = { - 'target': target, - 'bus': bus, - 'vendor': vendor, - 'model': model, - 'partitions': [] - } - - # group partitions by disk - for dev, partition in partitions_by_dev.items(): - for disk_dev, disk in disks_by_dev.items(): - if dev.startswith(disk_dev): - disk['partitions'].append(partition) - partition.pop('unmatched') - - # add separate partitions that did not match any disk - for partition in partitions_by_dev.values(): - if partition.pop('unmatched', False): - disks_by_dev[partition['target']] = partition - partition['partitions'] = [dict(partition)] - - # prepare flat list of disks - disks = disks_by_dev.values() - disks.sort(key=lambda d: d['vendor']) - - for disk in disks: - disk['partitions'].sort(key=lambda p: p['part_no']) - - return disks - - -def _list_disks_fdisk(): - try: - output = subprocess.check_output('fdisk -l 2>/dev/null', shell=True) - - except Exception as e: - logging.error('failed to list disks using "fdisk -l": %s' % e, exc_info=True) - - return [] - - disks = [] - disk = None - - def add_disk(disk): - logging.debug('found disk at "%s" on bus "%s": "%s %s"' % - (disk['target'], disk['bus'], disk['vendor'], disk['model'])) - - for part in disk['partitions']: - logging.debug('found partition "%s" at "%s" on bus "%s": "%s %s"' % - (part['part_no'], part['target'], part['bus'], part['vendor'], part['model'])) - - disks.append(disk) - - for line in output.split('\n'): - line = line.replace('*', '') - line = re.sub('\s+', ' ', line.strip()) - if not line: - continue - - if line.startswith('Disk /dev/'): - if disk and disk['partitions']: - add_disk(disk) - - parts = line.split() - - disk = { - 'target': parts[1].strip(':'), - 'bus': '', - 'vendor': '', - 'model': parts[2] + ' ' + parts[3].strip(','), - 'partitions': [] - } - - elif line.startswith('/dev/') and disk: - parts = line.split() - part_no = re.findall('\d+$', parts[0]) - partition = { - 'part_no': int(part_no[0]) if part_no else None, - 'target': parts[0], - 'bus': '', - 'vendor': '', - 'model': parts[4] + ' ' + ' '.join(parts[6:]), - } - - disk['partitions'].append(partition) - - if disk and disk['partitions']: - add_disk(disk) - - disks.sort(key=lambda d: d['target']) - - for disk in disks: - disk['partitions'].sort(key=lambda p: p['part_no']) - - return disks - - -def list_mounted_disks(): - mounted_disks = [] - - try: - disks = _list_disks() - mounts_by_target = dict((m['target'], m) for m in _list_mounts()) - - for disk in disks: - for partition in disk['partitions']: - mount = mounts_by_target.get(partition['target']) - if mount: - partition.update(mount) - - # filter out unmounted partitions - disk['partitions'] = [p for p in disk['partitions'] if p.get('mount_point')] - - # filter out unmounted disks - mounted_disks = [d for d in disks if d['partitions']] - - except Exception as e: - logging.error('failed to list mounted disks: %s' % e, exc_info=True) - - return mounted_disks - - -def list_mounted_partitions(): - mounted_partitions = {} - - try: - disks = _list_disks() - mounts_by_target = dict((m['target'], m) for m in _list_mounts()) - - for disk in disks: - for partition in disk['partitions']: - mount = mounts_by_target.get(partition['target']) - if mount: - partition.update(mount) - mounted_partitions[partition['target']] = partition - - except Exception as e: - logging.error('failed to list mounted partitions: %s' % e, exc_info=True) - - return mounted_partitions diff --git a/src/handlers.py b/src/handlers.py deleted file mode 100644 index 120d481..0000000 --- a/src/handlers.py +++ /dev/null @@ -1,1466 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import datetime -import json -import logging -import os -import re -import socket -import subprocess - -from tornado.web import RequestHandler, HTTPError, asynchronous -from tornado.ioloop import IOLoop - -import config -import mediafiles -import motionctl -import powerctl -import remote -import settings -import smbctl -import template -import update -import utils -import v4l2ctl - - -class BaseHandler(RequestHandler): - def get_data(self): - keys = self.request.arguments.keys() - data = dict([(key, self.get_argument(key)) for key in keys]) - - for key in self.request.files: - files = self.request.files[key] - if len(files) > 1: - data[key] = files - - elif len(files) > 0: - data[key] = files[0] - - else: - continue - - return data - - def render(self, template_name, content_type='text/html', **context): - self.set_header('Content-Type', content_type) - - content = template.render(template_name, **context) - self.finish(content) - - def finish_json(self, data={}): - self.set_header('Content-Type', 'application/json') - self.finish(json.dumps(data)) - - def get_current_user(self): - main_config = config.get_main() - - username = self.get_argument('_username', None) - signature = self.get_argument('_signature', None) - login = self.get_argument('_login', None) == 'true' - if (username == main_config.get('@admin_username') and - signature == utils.compute_signature(self.request.method, self.request.uri, self.request.body, main_config.get('@admin_password'))): - - return 'admin' - - elif not username and not main_config.get('@normal_password'): # no authentication required for normal user - return 'normal' - - elif (username == main_config.get('@normal_username') and - signature == utils.compute_signature(self.request.method, self.request.uri, self.request.body, main_config.get('@normal_password'))): - - return 'normal' - - elif username and username != '_' and login: - logging.error('authentication failed for user %(user)s' % {'user': username}) - - return None - - def _handle_request_exception(self, exception): - try: - if isinstance(exception, HTTPError): - logging.error(str(exception)) - self.set_status(exception.status_code) - self.finish_json({'error': exception.log_message or getattr(exception, 'reason', None) or str(exception)}) - - else: - logging.error(str(exception), exc_info=True) - self.set_status(500) - self.finish_json({'error': 'internal server error'}) - - except RuntimeError: - pass # nevermind - - @staticmethod - def auth(admin=False, prompt=True): - def decorator(func): - def wrapper(self, *args, **kwargs): - _admin = self.get_argument('_admin', None) == 'true' - - user = self.current_user - if (user is None) or (user != 'admin' and (admin or _admin)): - self.set_header('Content-Type', 'application/json') - - return self.finish_json({'error': 'unauthorized', 'prompt': prompt}) - - return func(self, *args, **kwargs) - - return wrapper - - return decorator - - def get(self, *args, **kwargs): - raise HTTPError(400, 'method not allowed') - - def post(self, *args, **kwargs): - raise HTTPError(400, 'method not allowed') - - -class NotFoundHandler(BaseHandler): - def get(self): - raise HTTPError(404, 'not found') - - def post(self): - raise HTTPError(404, 'not found') - - -class MainHandler(BaseHandler): - def get(self): - import motioneye - - # additional config - main_sections = config.get_additional_structure(camera=False, separators=True)[0] - camera_sections = config.get_additional_structure(camera=True, separators=True)[0] - - self.render('main.html', - frame=False, - version=motioneye.VERSION, - enable_update=False, - enable_reboot=settings.ENABLE_REBOOT, - add_remove_cameras=settings.ADD_REMOVE_CAMERAS, - main_sections=main_sections, - camera_sections=camera_sections, - hostname=socket.gethostname(), - title=self.get_argument('title', None), - admin_username=config.get_main().get('@admin_username'), - old_motion=config.is_old_motion()) - - -class ConfigHandler(BaseHandler): - @asynchronous - def get(self, camera_id=None, op=None): - if camera_id is not None: - camera_id = int(camera_id) - - if op == 'get': - self.get_config(camera_id) - - elif op == 'list': - self.list() - - elif op == 'backup': - self.backup() - - else: - raise HTTPError(400, 'unknown operation') - - @asynchronous - def post(self, camera_id=None, op=None): - if camera_id is not None: - camera_id = int(camera_id) - - if op == 'set': - self.set_config(camera_id) - - elif op == 'set_preview': - self.set_preview(camera_id) - - elif op == 'add': - self.add_camera() - - elif op == 'rem': - self.rem_camera(camera_id) - - elif op == 'restore': - self.restore() - - else: - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth(admin=True) - def get_config(self, camera_id): - if camera_id: - logging.debug('getting config for camera %(id)s' % {'id': camera_id}) - - if camera_id not in config.get_camera_ids(): - raise HTTPError(404, 'no such camera') - - local_config = config.get_camera(camera_id) - if utils.local_motion_camera(local_config): - ui_config = config.motion_camera_dict_to_ui(local_config) - - self.finish_json(ui_config) - - elif utils.remote_camera(local_config): - def on_response(remote_ui_config=None, error=None): - if error: - return self.finish_json({'error': 'Failed to get remote camera configuration for %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(local_config), 'msg': error}}) - - for key, value in local_config.items(): - remote_ui_config[key.replace('@', '')] = value - - # replace the real device URI with the remote camera URL - remote_ui_config['device_url'] = remote.pretty_camera_url(local_config) - self.finish_json(remote_ui_config) - - remote.get_config(local_config, on_response) - - else: # assuming simple mjpeg camera - ui_config = config.simple_mjpeg_camera_dict_to_ui(local_config) - - self.finish_json(ui_config) - - else: - logging.debug('getting main config') - - ui_config = config.main_dict_to_ui(config.get_main()) - self.finish_json(ui_config) - - @BaseHandler.auth(admin=True) - def set_config(self, camera_id): - try: - ui_config = json.loads(self.request.body) - - except Exception as e: - logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)}) - - raise - - camera_ids = config.get_camera_ids() - - def set_camera_config(camera_id, ui_config, on_finish): - logging.debug('setting config for camera %(id)s...' % {'id': camera_id}) - - if camera_id not in camera_ids: - raise HTTPError(404, 'no such camera') - - local_config = config.get_camera(camera_id) - if utils.local_motion_camera(local_config): - local_config = config.motion_camera_ui_to_dict(ui_config, local_config) - - config.set_camera(camera_id, local_config) - - on_finish(None, True) # (no error, motion needs restart) - - elif utils.remote_camera(local_config): - # update the camera locally - local_config['@enabled'] = ui_config['enabled'] - config.set_camera(camera_id, local_config) - - if ui_config.has_key('name'): - def on_finish_wrapper(error=None): - return on_finish(error, False) - - ui_config['enabled'] = True # never disable the camera remotely - remote.set_config(local_config, ui_config, on_finish_wrapper) - - else: - # when the ui config supplied has only the enabled state - # and no useful fields (such as "name"), - # the camera was probably disabled due to errors - on_finish(None, False) - - else: # assuming simple mjpeg camera - local_config = config.simple_mjpeg_camera_ui_to_dict(ui_config, local_config) - - config.set_camera(camera_id, local_config) - - on_finish(None, False) # (no error, motion doesn't need restart) - - def set_main_config(ui_config): - logging.debug('setting main config...') - - old_main_config = config.get_main() - old_admin_credentials = '%s:%s' % (old_main_config.get('@admin_username', ''), old_main_config.get('@admin_password', '')) - old_normal_credentials = '%s:%s' % (old_main_config.get('@normal_username', ''), old_main_config.get('@normal_password', '')) - - main_config = config.main_ui_to_dict(ui_config) - main_config.setdefault('thread', old_main_config.get('thread', [])) - admin_credentials = '%s:%s' % (main_config.get('@admin_username', ''), main_config.get('@admin_password', '')) - normal_credentials = '%s:%s' % (main_config.get('@normal_username', ''), main_config.get('@normal_password', '')) - - additional_configs = config.get_additional_structure(camera=False)[1] - reboot_config_names = [('@_' + c['name']) for c in additional_configs.values() if c.get('reboot')] - reboot_config_names.append('@admin_password') - reboot = bool([k for k in reboot_config_names if old_main_config.get(k) != main_config.get(k)]) - - config.set_main(main_config) - - reload = False - restart = False - - if admin_credentials != old_admin_credentials: - logging.debug('admin credentials changed, reload needed') - - reload = True - - if normal_credentials != old_normal_credentials: - logging.debug('surveillance credentials changed, all camera configs must be updated') - - # reconfigure all local cameras to update the stream authentication options - for camera_id in config.get_camera_ids(): - local_config = config.get_camera(camera_id) - if not utils.local_motion_camera(local_config): - continue - - ui_config = config.motion_camera_dict_to_ui(local_config) - local_config = config.motion_camera_ui_to_dict(ui_config, local_config) - - config.set_camera(camera_id, local_config) - - restart = True - - if reboot and settings.ENABLE_REBOOT: - logging.debug('system settings changed, reboot needed') - - else: - reboot = False - - return {'reload': reload, 'reboot': reboot, 'restart': restart} - - reload = False # indicates that browser should reload the page - reboot = [False] # indicates that the server will reboot immediately - restart = [False] # indicates that the local motion instance was modified and needs to be restarted - error = [None] - - def finish(): - if reboot[0]: - if settings.ENABLE_REBOOT: - def call_reboot(): - powerctl.reboot() - - ioloop = IOLoop.instance() - ioloop.add_timeout(datetime.timedelta(seconds=2), call_reboot) - return self.finish({'reload': False, 'reboot': True, 'error': None}) - - else: - reboot[0] = False - - if restart[0]: - logging.debug('motion needs to be restarted') - - motionctl.stop() - - if settings.SMB_SHARES: - logging.debug('updating SMB mounts') - stop, start = smbctl.update_mounts() # @UnusedVariable - - if start: - motionctl.start() - - else: - motionctl.start() - - self.finish({'reload': reload, 'reboot': reboot[0], 'error': error[0]}) - - if camera_id is not None: - if camera_id == 0: # multiple camera configs - if len(ui_config) > 1: - logging.debug('setting multiple configs') - - elif len(ui_config) == 0: - logging.warn('no configuration to set') - - self.finish() - - so_far = [0] - def check_finished(e, r): - restart[0] = restart[0] or r - error[0] = error[0] or e - so_far[0] += 1 - - if so_far[0] >= len(ui_config): # finished - finish() - - # make sure main config is handled first - items = ui_config.items() - items.sort(key=lambda (key, cfg): key != 'main') - - for key, cfg in items: - if key == 'main': - result = set_main_config(cfg) - reload = result['reload'] or reload - reboot[0] = result['reboot'] or reboot[0] - restart[0] = result['restart'] or restart[0] - check_finished(None, reload) - - else: - set_camera_config(int(key), cfg, check_finished) - - else: # single camera config - def on_finish(e, r): - error[0] = e - restart[0] = r - finish() - - set_camera_config(camera_id, ui_config, on_finish) - - else: # main config - result = set_main_config(ui_config) - reload = result['reload'] - reboot[0] = result['reboot'] - restart[0] = result['restart'] - - @BaseHandler.auth(admin=True) - def set_preview(self, camera_id): - try: - controls = json.loads(self.request.body) - - except Exception as e: - logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)}) - - raise - - camera_config = config.get_camera(camera_id) - if utils.v4l2_camera(camera_config): - device = camera_config['videodevice'] - - if 'brightness' in controls: - value = int(controls['brightness']) - logging.debug('setting brightness to %(value)s...' % {'value': value}) - - v4l2ctl.set_brightness(device, value) - - if 'contrast' in controls: - value = int(controls['contrast']) - logging.debug('setting contrast to %(value)s...' % {'value': value}) - - v4l2ctl.set_contrast(device, value) - - if 'saturation' in controls: - value = int(controls['saturation']) - logging.debug('setting saturation to %(value)s...' % {'value': value}) - - v4l2ctl.set_saturation(device, value) - - if 'hue' in controls: - value = int(controls['hue']) - logging.debug('setting hue to %(value)s...' % {'value': value}) - - v4l2ctl.set_hue(device, value) - - self.finish_json({}) - - elif utils.remote_camera(camera_config): - def on_response(error=None): - if error: - self.finish_json({'error': error}) - - else: - self.finish_json() - - remote.set_preview(camera_config, controls, on_response) - - else: # not supported - self.finish_json({'error': True}) - - @BaseHandler.auth() - def list(self): - logging.debug('listing cameras') - - proto = self.get_data().get('proto') - if proto == 'motioneye': # remote listing - def on_response(cameras=None, error=None): - if error: - self.finish_json({'error': error}) - - else: - cameras = [c for c in cameras if c.get('enabled')] - self.finish_json({'cameras': cameras}) - - 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}) - - else: - self.finish_json({'cameras': cameras}) - - 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: - self.finish_json({'error': error}) - - else: - self.finish_json({'cameras': cameras}) - - utils.test_mjpeg_url(self.get_data(), auth_modes=['basic', 'digest'], allow_jpeg=False, callback=on_response) - - elif proto == 'v4l2': - configured_devices = set() - for camera_id in config.get_camera_ids(): - data = config.get_camera(camera_id) - if utils.v4l2_camera(data): - configured_devices.add(data['videodevice']) - - cameras = [{'id': d[1], 'name': d[2]} for d in v4l2ctl.list_devices() - if (d[0] not in configured_devices) and (d[1] not in configured_devices)] - - self.finish_json({'cameras': cameras}) - - else: # assuming local motionEye camera listing - cameras = [] - camera_ids = config.get_camera_ids() - if not config.get_main().get('@enabled'): - camera_ids = [] - - length = [len(camera_ids)] - def check_finished(): - if len(cameras) == length[0]: - cameras.sort(key=lambda c: c['id']) - self.finish_json({'cameras': cameras}) - - def on_response_builder(camera_id, local_config): - def on_response(remote_ui_config=None, error=None): - if error: - cameras.append({ - 'id': camera_id, - 'name': '<' + remote.pretty_camera_url(local_config) + '>', - 'enabled': False, - 'streaming_framerate': 1, - 'framerate': 1 - }) - - else: - remote_ui_config['id'] = camera_id - - if not remote_ui_config['enabled'] and local_config['@enabled']: - # if a remote camera is disabled, make sure it's disabled locally as well - local_config['@enabled'] = False - config.set_camera(camera_id, local_config) - - elif remote_ui_config['enabled'] and not local_config['@enabled']: - # if a remote camera is locally disabled, make sure the remote config says the same thing - remote_ui_config['enabled'] = False - - for key, value in local_config.items(): - remote_ui_config[key.replace('@', '')] = value - - cameras.append(remote_ui_config) - - check_finished() - - return on_response - - for camera_id in camera_ids: - local_config = config.get_camera(camera_id) - if local_config is None: - continue - - if utils.local_motion_camera(local_config): - ui_config = config.motion_camera_dict_to_ui(local_config) - cameras.append(ui_config) - check_finished() - - elif utils.remote_camera(local_config): - if local_config.get('@enabled') or self.get_argument('force', None) == 'true': - remote.get_config(local_config, on_response_builder(camera_id, local_config)) - - else: # don't try to reach the remote of the camera is disabled - on_response_builder(camera_id, local_config)(error=True) - - else: # assuming simple mjpeg camera - ui_config = config.simple_mjpeg_camera_dict_to_ui(local_config) - cameras.append(ui_config) - check_finished() - - if length[0] == 0: - self.finish_json({'cameras': []}) - - @BaseHandler.auth(admin=True) - def add_camera(self): - logging.debug('adding new camera') - - try: - device_details = json.loads(self.request.body) - - except Exception as e: - logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)}) - - raise - - camera_config = config.add_camera(device_details) - - if utils.local_motion_camera(camera_config): - motionctl.stop() - - if settings.SMB_SHARES: - stop, start = smbctl.update_mounts() # @UnusedVariable - - if start: - motionctl.start() - - else: - motionctl.start() - - ui_config = config.motion_camera_dict_to_ui(camera_config) - - self.finish_json(ui_config) - - elif utils.remote_camera(camera_config): - def on_response(remote_ui_config=None, error=None): - if error: - return self.finish_json({'error': error}) - - for key, value in camera_config.items(): - remote_ui_config[key.replace('@', '')] = value - - self.finish_json(remote_ui_config) - - remote.get_config(camera_config, on_response) - - else: # assuming simple mjpeg camera - ui_config = config.simple_mjpeg_camera_dict_to_ui(camera_config) - - self.finish_json(ui_config) - - @BaseHandler.auth(admin=True) - def rem_camera(self, camera_id): - logging.debug('removing camera %(id)s' % {'id': camera_id}) - - local = utils.local_motion_camera(config.get_camera(camera_id)) - config.rem_camera(camera_id) - - if local: - motionctl.stop() - motionctl.start() - - self.finish_json() - - @BaseHandler.auth(admin=True) - def backup(self): - content = config.backup() - - filename = 'motioneye-config.tar.gz' - self.set_header('Content-Type', 'application/x-compressed') - self.set_header('Content-Disposition', 'attachment; filename=' + filename + ';') - - self.finish(content) - - @BaseHandler.auth(admin=True) - def restore(self): - try: - content = self.request.files['files'][0]['body'] - - except KeyError: - raise HTTPError(400, 'file attachment required') - - result = config.restore(content) - if result: - self.finish_json({'ok': True, 'reboot': result['reboot']}) - - else: - self.finish_json({'ok': False}) - - -class PictureHandler(BaseHandler): - @asynchronous - def get(self, camera_id, op, filename=None, group=None): - if camera_id is not None: - camera_id = int(camera_id) - if camera_id not in config.get_camera_ids(): - raise HTTPError(404, 'no such camera') - - if op == 'current': - self.current(camera_id) - - elif op == 'list': - self.list(camera_id) - - elif op == 'frame': - self.frame(camera_id) - - elif op == 'download': - self.download(camera_id, filename) - - elif op == 'preview': - self.preview(camera_id, filename) - - elif op == 'zipped': - self.zipped(camera_id, group) - - elif op == 'timelapse': - self.timelapse(camera_id, group) - - else: - raise HTTPError(400, 'unknown operation') - - @asynchronous - def post(self, camera_id, op, filename=None, group=None): - if camera_id is not None: - camera_id = int(camera_id) - if camera_id not in config.get_camera_ids(): - raise HTTPError(404, 'no such camera') - - if op == 'delete': - self.delete(camera_id, filename) - - elif op == 'delete_all': - self.delete_all(camera_id, group) - - else: - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth(prompt=False) - def current(self, camera_id): - self.set_header('Content-Type', 'image/jpeg') - - width = self.get_argument('width', None) - height = self.get_argument('height', None) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - picture = mediafiles.get_current_picture(camera_config, - width=width, - height=height) - - self.set_cookie('motion_detected_' + str(camera_id), str(motionctl.is_motion_detected(camera_id)).lower()) - self.try_finish(picture) - - elif utils.remote_camera(camera_config): - def on_response(motion_detected=False, picture=None, error=None): - self.set_cookie('motion_detected_' + str(camera_id), str(motion_detected).lower()) - self.try_finish(picture) - - remote.get_current_picture(camera_config, width=width, height=height, callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - - @BaseHandler.auth() - def list(self, camera_id): - logging.debug('listing pictures for camera %(id)s' % {'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - def on_media_list(media_list): - if media_list is None: - return self.finish_json({'error': 'Failed to get pictures list.'}) - - self.finish_json({ - 'mediaList': media_list, - 'cameraName': camera_config['@name'] - }) - - mediafiles.list_media(camera_config, media_type='picture', - callback=on_media_list, prefix=self.get_argument('prefix', None)) - - elif utils.remote_camera(camera_config): - def on_response(remote_list=None, error=None): - if error: - return self.finish_json({'error': 'Failed to get picture list for %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.finish_json(remote_list) - - remote.list_media(camera_config, media_type='picture', prefix=self.get_argument('prefix', None), callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - def frame(self, camera_id): - camera_config = config.get_camera(camera_id) - - if utils.local_motion_camera(camera_config) or utils.simple_mjpeg_camera(camera_config) or self.get_argument('title', None) is not None: - self.render('main.html', - frame=True, - camera_id=camera_id, - camera_config=camera_config, - title=self.get_argument('title', camera_config.get('@name', '')), - admin_username=config.get_main().get('@admin_username')) - - elif utils.remote_camera(camera_config): - def on_response(remote_ui_config=None, error=None): - if error: - return self.render('main.html', - frame=True, - camera_id=camera_id, - camera_config=camera_config, - title=self.get_argument('title', '')) - - # issue a fake motion_camera_ui_to_dict() call to transform - # the remote UI values into motion config directives - remote_config = config.motion_camera_ui_to_dict(remote_ui_config) - - self.render('main.html', - frame=True, - camera_id=camera_id, - camera_config=remote_config, - title=self.get_argument('title', remote_config['@name']), - admin_username=config.get_main().get('@admin_username')) - - remote.get_config(camera_config, on_response) - - @BaseHandler.auth() - def download(self, camera_id, filename): - logging.debug('downloading picture %(filename)s of camera %(id)s' % { - 'filename': filename, 'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - content = mediafiles.get_media_content(camera_config, filename, 'picture') - - pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename) - self.set_header('Content-Type', 'image/jpeg') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') - - self.finish(content) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to download picture from %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - pretty_filename = os.path.basename(filename) # no camera name available w/o additional request - self.set_header('Content-Type', 'image/jpeg') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') - - self.finish(response) - - remote.get_media_content(camera_config, filename=filename, media_type='picture', callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth() - def preview(self, camera_id, filename): - logging.debug('previewing picture %(filename)s of camera %(id)s' % { - 'filename': filename, 'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - content = mediafiles.get_media_preview(camera_config, filename, 'picture', - width=self.get_argument('width', None), - height=self.get_argument('height', None)) - - if content: - self.set_header('Content-Type', 'image/jpeg') - - else: - self.set_header('Content-Type', 'image/svg+xml') - content = open(os.path.join(settings.STATIC_PATH, 'img', 'no-preview.svg')).read() - - self.finish(content) - - elif utils.remote_camera(camera_config): - def on_response(content=None, error=None): - if content: - self.set_header('Content-Type', 'image/jpeg') - - else: - self.set_header('Content-Type', 'image/svg+xml') - content = open(os.path.join(settings.STATIC_PATH, 'img', 'no-preview.svg')).read() - - self.finish(content) - - remote.get_media_preview(camera_config, filename=filename, media_type='picture', - width=self.get_argument('width', None), - height=self.get_argument('height', None), - callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth(admin=True) - def delete(self, camera_id, filename): - logging.debug('deleting picture %(filename)s of camera %(id)s' % { - 'filename': filename, 'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - try: - mediafiles.del_media_content(camera_config, filename, 'picture') - self.finish_json() - - except Exception as e: - self.finish_json({'error': unicode(e)}) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to delete picture from %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.finish_json() - - remote.del_media_content(camera_config, filename=filename, media_type='picture', callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth() - def zipped(self, camera_id, group): - key = self.get_argument('key', None) - camera_config = config.get_camera(camera_id) - - if key: - logging.debug('serving zip file for group %(group)s of camera %(id)s with key %(key)s' % { - 'group': group, 'id': camera_id, 'key': key}) - - if utils.local_motion_camera(camera_config): - data = mediafiles.get_prepared_cache(key) - if not data: - logging.error('prepared cache data for key "%s" does not exist' % key) - - raise HTTPError(404, 'no such key') - - pretty_filename = camera_config['@name'] + '_' + group - pretty_filename = re.sub('[^a-zA-Z0-9]', '_', pretty_filename) - - self.set_header('Content-Type', 'application/zip') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + '.zip;') - self.finish(data) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to download zip file from %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.set_header('Content-Type', response['content_type']) - self.set_header('Content-Disposition', response['content_disposition']) - self.finish(response['data']) - - remote.get_zipped_content(camera_config, media_type='picture', key=key, group=group, callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - else: # prepare - logging.debug('preparing zip file for group %(group)s of camera %(id)s' % { - 'group': group, 'id': camera_id}) - - if utils.local_motion_camera(camera_config): - def on_zip(data): - if data is None: - return self.finish_json({'error': 'Failed to create zip file.'}) - - key = mediafiles.set_prepared_cache(data) - logging.debug('prepared zip file for group %(group)s of camera %(id)s with key %(key)s' % { - 'group': group, 'id': camera_id, 'key': key}) - self.finish_json({'key': key}) - - mediafiles.get_zipped_content(camera_config, media_type='picture', group=group, callback=on_zip) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to make zip file at %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.finish_json({'key': response['key']}) - - remote.make_zipped_content(camera_config, media_type='picture', group=group, callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth() - def timelapse(self, camera_id, group): - key = self.get_argument('key', None) - check = self.get_argument('check', False) - camera_config = config.get_camera(camera_id) - - if key: # download - logging.debug('serving timelapse movie for group %(group)s of camera %(id)s with key %(key)s' % { - 'group': group, 'id': camera_id, 'key': key}) - - if utils.local_motion_camera(camera_config): - data = mediafiles.get_prepared_cache(key) - if data is None: - logging.error('prepared cache data for key "%s" does not exist' % key) - - raise HTTPError(404, 'no such key') - - pretty_filename = camera_config['@name'] + '_' + group - pretty_filename = re.sub('[^a-zA-Z0-9]', '_', pretty_filename) - - self.set_header('Content-Type', 'video/x-msvideo') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + '.avi;') - self.finish(data) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to download timelapse movie from %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.set_header('Content-Type', response['content_type']) - self.set_header('Content-Disposition', response['content_disposition']) - self.finish(response['data']) - - remote.get_timelapse_movie(camera_config, key, group=group, callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - elif check: - logging.debug('checking timelapse movie status for group %(group)s of camera %(id)s' % { - 'group': group, 'id': camera_id}) - - if utils.local_motion_camera(camera_config): - status = mediafiles.check_timelapse_movie() - if status['progress'] == -1 and status['data']: - key = mediafiles.set_prepared_cache(status['data']) - logging.debug('prepared timelapse movie for group %(group)s of camera %(id)s with key %(key)s' % { - 'group': group, 'id': camera_id, 'key': key}) - self.finish_json({'key': key, 'progress': -1}) - - else: - self.finish_json(status) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to check timelapse movie progress at %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - if response['progress'] == -1 and response.get('key'): - self.finish_json({'key': response['key'], 'progress': -1}) - - else: - self.finish_json(response) - - remote.check_timelapse_movie(camera_config, group=group, callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - else: # start timelapse - interval = int(self.get_argument('interval')) - framerate = int(self.get_argument('framerate')) - - logging.debug('preparing timelapse movie for group %(group)s of camera %(id)s with rate %(framerate)s/%(int)s' % { - 'group': group, 'id': camera_id, 'framerate': framerate, 'int': interval}) - - if utils.local_motion_camera(camera_config): - status = mediafiles.check_timelapse_movie() - if status['progress'] != -1: - self.finish_json({'progress': status['progress']}) # timelapse already active - - else: - mediafiles.make_timelapse_movie(camera_config, framerate, interval, group=group) - self.finish_json({'progress': -1}) - - elif utils.remote_camera(camera_config): - def on_status(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to make timelapse movie at %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - if response['progress'] != -1: - return self.finish_json({'progress': response['progress']}) # timelapse already active - - def on_make(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to make timelapse movie at %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.finish_json({'progress': -1}) - - remote.make_timelapse_movie(camera_config, framerate, interval, group=group, callback=on_make) - - remote.check_timelapse_movie(camera_config, group=group, callback=on_status) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth(admin=True) - def delete_all(self, camera_id, group): - logging.debug('deleting picture group %(group)s of camera %(id)s' % { - 'group': group, 'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - try: - mediafiles.del_media_group(camera_config, group, 'picture') - self.finish_json() - - except Exception as e: - self.finish_json({'error': unicode(e)}) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to delete picture group from %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.finish_json() - - remote.del_media_group(camera_config, group=group, media_type='picture', callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - def try_finish(self, content): - try: - self.finish(content) - - except IOError as e: - logging.warning('could not write response: %(msg)s' % {'msg': unicode(e)}) - - -class MovieHandler(BaseHandler): - @asynchronous - def get(self, camera_id, op, filename=None): - if camera_id is not None: - camera_id = int(camera_id) - if camera_id not in config.get_camera_ids(): - raise HTTPError(404, 'no such camera') - - if op == 'list': - self.list(camera_id) - - elif op == 'download': - self.download(camera_id, filename) - - elif op == 'preview': - self.preview(camera_id, filename) - - else: - raise HTTPError(400, 'unknown operation') - - @asynchronous - def post(self, camera_id, op, filename=None, group=None): - if camera_id is not None: - camera_id = int(camera_id) - if camera_id not in config.get_camera_ids(): - raise HTTPError(404, 'no such camera') - - if op == 'delete': - self.delete(camera_id, filename) - - elif op == 'delete_all': - self.delete_all(camera_id, group) - - else: - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth() - def list(self, camera_id): - logging.debug('listing movies for camera %(id)s' % {'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - def on_media_list(media_list): - if media_list is None: - return self.finish_json({'error': 'Failed to get movies list.'}) - - self.finish_json({ - 'mediaList': media_list, - 'cameraName': camera_config['@name'] - }) - - mediafiles.list_media(camera_config, media_type='movie', - callback=on_media_list, prefix=self.get_argument('prefix', None)) - - elif utils.remote_camera(camera_config): - def on_response(remote_list=None, error=None): - if error: - return self.finish_json({'error': 'Failed to get movie list for %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.finish_json(remote_list) - - remote.list_media(camera_config, media_type='movie', prefix=self.get_argument('prefix', None), callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth() - def download(self, camera_id, filename): - logging.debug('downloading movie %(filename)s of camera %(id)s' % { - 'filename': filename, 'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - content = mediafiles.get_media_content(camera_config, filename, 'movie') - - pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename) - self.set_header('Content-Type', 'video/mpeg') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') - - self.finish(content) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to download movie from %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - pretty_filename = os.path.basename(filename) # no camera name available w/o additional request - self.set_header('Content-Type', 'video/mpeg') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') - - self.finish(response) - - remote.get_media_content(camera_config, filename=filename, media_type='movie', callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth() - def preview(self, camera_id, filename): - logging.debug('previewing movie %(filename)s of camera %(id)s' % { - 'filename': filename, 'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - content = mediafiles.get_media_preview(camera_config, filename, 'movie', - width=self.get_argument('width', None), - height=self.get_argument('height', None)) - - if content: - self.set_header('Content-Type', 'image/jpeg') - - else: - self.set_header('Content-Type', 'image/svg+xml') - content = open(os.path.join(settings.STATIC_PATH, 'img', 'no-preview.svg')).read() - - self.finish(content) - - elif utils.remote_camera(camera_config): - def on_response(content=None, error=None): - if content: - self.set_header('Content-Type', 'image/jpeg') - - else: - self.set_header('Content-Type', 'image/svg+xml') - content = open(os.path.join(settings.STATIC_PATH, 'img', 'no-preview.svg')).read() - - self.finish(content) - - remote.get_media_preview(camera_config, filename=filename, media_type='movie', - width=self.get_argument('width', None), - height=self.get_argument('height', None), - callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth(admin=True) - def delete(self, camera_id, filename): - logging.debug('deleting movie %(filename)s of camera %(id)s' % { - 'filename': filename, 'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - try: - mediafiles.del_media_content(camera_config, filename, 'movie') - self.finish_json() - - except Exception as e: - self.finish_json({'error': unicode(e)}) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to delete movie from %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.finish_json() - - remote.del_media_content(camera_config, filename=filename, media_type='movie', callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth(admin=True) - def delete_all(self, camera_id, group): - logging.debug('deleting movie group %(group)s of camera %(id)s' % { - 'group': group, 'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.local_motion_camera(camera_config): - try: - mediafiles.del_media_group(camera_config, group, 'movie') - self.finish_json() - - except Exception as e: - self.finish_json({'error': unicode(e)}) - - elif utils.remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to delete movie group from %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - self.finish_json() - - remote.del_media_group(camera_config, group=group, media_type='movie', callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - - -class RelayEventHandler(BaseHandler): - @BaseHandler.auth(admin=True) - def post(self): - event = self.get_argument('event') - thread_id = int(self.get_argument('thread_id')) - logging.debug('recevied relayed event %(event)s for thread id %(id)s' % {'event': event, 'id': thread_id}) - - camera_id = motionctl.thread_id_to_camera_id(thread_id) - try: - camera_config = config.get_camera(camera_id) - - except: - logging.warn('ignoring event for remote camera with id %s (probably removed)' % camera_id) - return self.finish_json() - - if not utils.local_motion_camera(camera_config): - logging.warn('ignoring event for non-local camera with id %s' % camera_id) - return self.finish_json() - - if event == 'start': - if not camera_config['@motion_detection']: - logging.debug('ignoring start event for camera with id %s and motion detection disabled' % camera_id) - return self.finish_json() - - motionctl.set_motion_detected(camera_id, True) - - elif event == 'stop': - motionctl.set_motion_detected(camera_id, False) - - else: - logging.warn('unknown event %s' % event) - - self.finish_json() - - -class LogHandler(BaseHandler): - LOGS = { - 'motion': (os.path.join(settings.LOG_PATH, 'motion.log'), 'motion.log'), - } - - @BaseHandler.auth(admin=True) - def get(self, name): - log = self.LOGS.get(name) - if log is None: - raise HTTPError(404, 'no such log') - - (path, filename) = log - - self.set_header('Content-Type', 'text/plain') - self.set_header('Content-Disposition', 'attachment; filename=' + filename + ';') - - if path.startswith('/'): # an actual path - logging.debug('serving log file "%s" from "%s"' % (filename, path)) - - with open(path) as f: - self.finish(f.read()) - - else: # a command to execute - logging.debug('serving log file "%s" from command "%s"' % (filename, path)) - - try: - output = subprocess.check_output(path, shell=True) - - except Exception as e: - output = 'failed to execute command: %s' % e - - self.finish(output) - - -class UpdateHandler(BaseHandler): - @BaseHandler.auth(admin=True) - def get(self): - logging.debug('listing versions') - - versions = update.get_all_versions() - current_version = update.get_version() - update_version = None - if versions and update.compare_versions(versions[-1], current_version) > 0: - update_version = versions[-1] - - self.finish_json({ - 'update_version': update_version, - 'current_version': current_version - }) - - @BaseHandler.auth(admin=True) - def post(self): - version = self.get_argument('version') - - logging.debug('performing update to version %(version)s' % {'version': version}) - - result = update.perform_update(version) - - self.finish_json(result) - - -class PowerHandler(BaseHandler): - @BaseHandler.auth(admin=True) - def post(self, op): - if op == 'shutdown': - self.shut_down() - - elif op == 'reboot': - self.reboot() - - def shut_down(self): - IOLoop.instance().add_timeout(datetime.timedelta(seconds=2), powerctl.shut_down) - - def reboot(self): - IOLoop.instance().add_timeout(datetime.timedelta(seconds=2), powerctl.reboot) - - -class VersionHandler(BaseHandler): - def get(self): - self.render('version.html', - version=update.get_version(), - hostname=socket.gethostname()) - - post = get - - -# this will only trigger the login mechanism on the client side, if required -class LoginHandler(BaseHandler): - @BaseHandler.auth() - def get(self): - self.finish_json() - - def post(self): - self.set_header('Content-Type', 'text/html') - self.finish() diff --git a/src/mediafiles.py b/src/mediafiles.py deleted file mode 100644 index c4f615e..0000000 --- a/src/mediafiles.py +++ /dev/null @@ -1,801 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import datetime -import errno -import fcntl -import functools -import hashlib -import logging -import multiprocessing -import os.path -import re -import stat -import StringIO -import subprocess -import time -import tornado -import zipfile - -from PIL import Image -from tornado import ioloop - -import config -import settings -import utils - - -_PICTURE_EXTS = ['.jpg'] -_MOVIE_EXTS = ['.avi', '.mp4'] - -# a cache list of paths to movies without preview -_previewless_movie_files = [] - -# a cache of prepared files (whose preparing time is significant) -_prepared_files = {} - -_timelapse_process = None -_timelapse_data = None - - -def _list_media_files(dir, exts, prefix=None): - media_files = [] - - if prefix is not None: - if prefix == 'ungrouped': - prefix = '' - - root = os.path.join(dir, prefix) - for name in os.listdir(root): - if name == 'lastsnap.jpg' or name.startswith('.'): # ignore the lastsnap.jpg and hidden files - continue - - full_path = os.path.join(root, name) - try: - st = os.stat(full_path) - - except Exception as e: - logging.error('stat failed: ' + unicode(e)) - continue - - if not stat.S_ISREG(st.st_mode): # not a regular file - continue - - full_path_lower = full_path.lower() - if not [e for e in exts if full_path_lower.endswith(e)]: - continue - - media_files.append((full_path, st)) - - else: - for root, dirs, files in os.walk(dir): # @UnusedVariable # TODO os.walk can be rewritten to return stat info - for name in files: - if name == 'lastsnap.jpg' or name.startswith('.'): # ignore the lastsnap.jpg and hidden files - continue - - full_path = os.path.join(root, name) - try: - st = os.stat(full_path) - - except Exception as e: - logging.error('stat failed: ' + unicode(e)) - continue - - if not stat.S_ISREG(st.st_mode): # not a regular file - continue - - full_path_lower = full_path.lower() - if not [e for e in exts if full_path_lower.endswith(e)]: - continue - - media_files.append((full_path, st)) - - return media_files - - -def _remove_older_files(dir, moment, exts): - for (full_path, st) in _list_media_files(dir, exts): - file_moment = datetime.datetime.fromtimestamp(st.st_mtime) - if file_moment < moment: - logging.debug('removing file %(path)s...' % {'path': full_path}) - - # remove the file itself - try: - os.remove(full_path) - - except OSError as e: - if e.errno == errno.ENOENT: - pass # the file might have been removed in the meantime - - else: - logging.error('failed to remove %s: %s' % (full_path, e)) - - # remove the parent directories if empty or contain only thumb files - dir_path = os.path.dirname(full_path) - if not os.path.exists(dir_path): - continue - - listing = os.listdir(dir_path) - thumbs = [l for l in listing if l.endswith('.thumb')] - - if len(listing) == len(thumbs): # only thumbs - for p in thumbs: - try: - os.remove(os.path.join(dir_path, p)) - - except: - logging.error('failed to remove %s: %s' % (p, e)) - - if not listing or len(listing) == len(thumbs): - # this will possibly cause following paths that are in the media files for loop - # to be removed in advance; the os.remove call will raise ENOENT which is silently ignored - logging.debug('removing empty directory %(path)s...' % {'path': dir_path}) - try: - os.removedirs(dir_path) - - except: - logging.error('failed to remove %s: %s' % (dir_path, e)) - - -def find_ffmpeg(): - try: - return subprocess.check_output('which ffmpeg', shell=True).strip() - - except subprocess.CalledProcessError: # not found - return None - - -def cleanup_media(media_type): - logging.debug('cleaning up %(media_type)ss...' % {'media_type': media_type}) - - if media_type == 'picture': - exts = _PICTURE_EXTS - - elif media_type == 'movie': - exts = _MOVIE_EXTS + ['.thumb'] - - for camera_id in config.get_camera_ids(): - camera_config = config.get_camera(camera_id) - if not utils.local_motion_camera(camera_config): - continue - - preserve_media = camera_config.get('@preserve_%(media_type)ss' % {'media_type': media_type}, 0) - if preserve_media == 0: - return # preserve forever - - still_images_enabled = bool( - ((camera_config['emulate_motion'] or camera_config['output_pictures']) and camera_config['picture_filename']) or - (camera_config['snapshot_interval'] and camera_config['snapshot_filename'])) - - movies_enabled = camera_config['ffmpeg_output_movies'] - - if media_type == 'picture' and not still_images_enabled: - continue # only cleanup pictures for cameras with still images enabled - - elif media_type == 'movie' and not movies_enabled: - continue # only cleanup movies for cameras with movies enabled - - preserve_moment = datetime.datetime.now() - datetime.timedelta(days=preserve_media) - - target_dir = camera_config.get('target_dir') - _remove_older_files(target_dir, preserve_moment, exts=exts) - - -def make_movie_preview(camera_config, full_path): - framerate = camera_config['framerate'] - pre_capture = camera_config['pre_capture'] - offs = pre_capture / framerate - offs = max(4, offs * 2) - - logging.debug('creating movie preview for %(path)s with an offset of %(offs)s seconds...' % { - 'path': full_path, 'offs': offs}) - - cmd = 'ffmpeg -i "%(path)s" -f mjpeg -vframes 1 -ss %(offs)s -y %(path)s.thumb' - - try: - subprocess.check_output(cmd % {'path': full_path, 'offs': offs}, shell=True, stderr=subprocess.STDOUT) - - except subprocess.CalledProcessError as e: - logging.error('failed to create movie preview for %(path)s: %(msg)s' % { - 'path': full_path, 'msg': unicode(e)}) - - return None - - try: - st = os.stat(full_path + '.thumb') - - except os.error: - logging.error('failed to create movie preview for %(path)s: ffmpeg error' % { - 'path': full_path}) - - return None - - if st.st_size == 0: - logging.debug('movie is too short, grabbing first frame from %(path)s...' % {'path': full_path}) - - # try again, this time grabbing the very first frame - try: - subprocess.check_output(cmd % {'path': full_path, 'offs': 0}, shell=True, stderr=subprocess.STDOUT) - - except subprocess.CalledProcessError as e: - logging.error('failed to create movie preview for %(path)s: %(msg)s' % { - 'path': full_path, 'msg': unicode(e)}) - - return None - - return full_path + '.thumb' - - -def make_next_movie_preview(): - global _previewless_movie_files - - logging.debug('making preview for the next movie...') - - if _previewless_movie_files: - (camera_config, path) = _previewless_movie_files.pop(0) - - make_movie_preview(camera_config, path) - - else: - logging.debug('gathering movies without preview...') - - count = 0 - for camera_id in config.get_camera_ids(): - camera_config = config.get_camera(camera_id) - if not utils.local_motion_camera(camera_config): - continue - - target_dir = camera_config['target_dir'] - - for (full_path, st) in _list_media_files(target_dir, _MOVIE_EXTS): # @UnusedVariable - if os.path.exists(full_path + '.thumb'): - continue - - logging.debug('found a movie without preview: %(path)s' % { - 'path': full_path}) - - _previewless_movie_files.append((camera_config, full_path)) - count += 1 - - logging.debug('found %(count)d movies without preview' % {'count': count}) - - if count: - make_next_movie_preview() - - -def list_media(camera_config, media_type, callback, prefix=None): - target_dir = camera_config.get('target_dir') - - if media_type == 'picture': - exts = _PICTURE_EXTS - - elif media_type == 'movie': - exts = _MOVIE_EXTS - - # create a subprocess to retrieve media files - def do_list_media(pipe): - mf = _list_media_files(target_dir, exts=exts, prefix=prefix) - for (p, st) in mf: - path = p[len(target_dir):] - if not path.startswith('/'): - path = '/' + path - - timestamp = st.st_mtime - size = st.st_size - - pipe.send({ - 'path': path, - 'momentStr': utils.pretty_date_time(datetime.datetime.fromtimestamp(timestamp)), - 'momentStrShort': utils.pretty_date_time(datetime.datetime.fromtimestamp(timestamp), short=True), - 'sizeStr': utils.pretty_size(size), - 'timestamp': timestamp - }) - - pipe.close() - - logging.debug('starting media listing process...') - - (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False) - process = multiprocessing.Process(target=do_list_media, args=(child_pipe, )) - process.start() - - # poll the subprocess to see when it has finished - started = datetime.datetime.now() - media_list = [] - - def read_media_list(): - while parent_pipe.poll(): - media_list.append(parent_pipe.recv()) - - def poll_process(): - ioloop = tornado.ioloop.IOLoop.instance() - if process.is_alive(): # not finished yet - now = datetime.datetime.now() - delta = now - started - if delta.seconds < 120: - ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_process) - read_media_list() - - else: # process did not finish within 2 minutes - logging.error('timeout waiting for the media listing process to finish') - - callback(None) - - else: # finished - read_media_list() - logging.debug('media listing process has returned %(count)s files' % {'count': len(media_list)}) - callback(media_list) - - poll_process() - - -def get_media_content(camera_config, path, media_type): - target_dir = camera_config.get('target_dir') - - full_path = os.path.join(target_dir, path) - - try: - with open(full_path) as f: - return f.read() - - except Exception as e: - logging.error('failed to read file %(path)s: %(msg)s' % { - 'path': full_path, 'msg': unicode(e)}) - - return None - - -def get_zipped_content(camera_config, media_type, group, callback): - target_dir = camera_config.get('target_dir') - - if media_type == 'picture': - exts = _PICTURE_EXTS - - elif media_type == 'movie': - exts = _MOVIE_EXTS - - working = multiprocessing.Value('b') - working.value = True - - # create a subprocess to add files to zip - def do_zip(pipe): - mf = _list_media_files(target_dir, exts=exts, prefix=group) - paths = [] - for (p, st) in mf: # @UnusedVariable - path = p[len(target_dir):] - if path.startswith('/'): - path = path[1:] - - paths.append(path) - - zip_filename = os.path.join(settings.MEDIA_PATH, '.zip-%s' % int(time.time())) - logging.debug('adding %d files to zip file "%s"' % (len(paths), zip_filename)) - - try: - with zipfile.ZipFile(zip_filename, mode='w') as f: - for path in paths: - full_path = os.path.join(target_dir, path) - f.write(full_path, path) - - except Exception as e: - logging.error('failed to create zip file "%s": %s' % (zip_filename, e)) - - working.value = False - pipe.close() - return - - logging.debug('reading zip file "%s" into memory' % zip_filename) - - try: - with open(zip_filename, mode='r') as f: - data = f.read() - - working.value = False - pipe.send(data) - logging.debug('zip data ready') - - except Exception as e: - logging.error('failed to read zip file "%s": %s' % (zip_filename, e)) - working.value = False - - finally: - os.remove(zip_filename) - pipe.close() - - logging.debug('starting zip process...') - - (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False) - process = multiprocessing.Process(target=do_zip, args=(child_pipe, )) - process.start() - - # poll the subprocess to see when it has finished - started = datetime.datetime.now() - - def poll_process(): - ioloop = tornado.ioloop.IOLoop.instance() - if working.value: - now = datetime.datetime.now() - delta = now - started - if delta.seconds < settings.ZIP_TIMEOUT: - ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_process) - - else: # process did not finish within 2 minutes - logging.error('timeout waiting for the zip process to finish') - - callback(None) - - else: # finished - try: - data = parent_pipe.recv() - logging.debug('zip process has returned %d bytes' % len(data)) - - except: - data = None - - callback(data) - - poll_process() - - -def make_timelapse_movie(camera_config, framerate, interval, group): - global _timelapse_process - global _timelapse_data - - target_dir = camera_config.get('target_dir') - - # create a subprocess to retrieve media files - def do_list_media(pipe): - mf = _list_media_files(target_dir, exts=_PICTURE_EXTS, prefix=group) - for (p, st) in mf: - timestamp = st.st_mtime - - pipe.send({ - 'path': p, - 'timestamp': timestamp - }) - - pipe.close() - - logging.debug('starting media listing process...') - - (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False) - _timelapse_process = multiprocessing.Process(target=do_list_media, args=(child_pipe, )) - _timelapse_process.progress = 0 - _timelapse_process.start() - _timelapse_data = None - - started = [datetime.datetime.now()] - media_list = [] - - tmp_filename = os.path.join(settings.MEDIA_PATH, '.%s.avi' % int(time.time())) - - def read_media_list(): - while parent_pipe.poll(): - media_list.append(parent_pipe.recv()) - - def poll_media_list_process(): - ioloop = tornado.ioloop.IOLoop.instance() - if _timelapse_process.is_alive(): # not finished yet - now = datetime.datetime.now() - delta = now - started[0] - if delta.seconds < 300: # the subprocess has 5 minutes to complete its job - ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_media_list_process) - read_media_list() - - else: # process did not finish within 2 minutes - logging.error('timeout waiting for the media listing process to finish') - - _timelapse_process.progress = -1 - - else: # finished - read_media_list() - logging.debug('media listing process has returned %(count)s files' % {'count': len(media_list)}) - - if not media_list: - _timelapse_process.progress = -1 - - return - - pictures = select_pictures(media_list) - make_movie(pictures) - - def select_pictures(media_list): - media_list.sort(key=lambda e: e['timestamp']) - start = media_list[0]['timestamp'] - slices = {} - max_idx = 0 - for m in media_list: - offs = m['timestamp'] - start - pos = float(offs) / interval - 0.5 - idx = int(round(pos)) - max_idx = idx - m['delta'] = abs(pos - idx) - slices.setdefault(idx, []).append(m) - - selected = [] - for i in xrange(max_idx + 1): - slice = slices.get(i) - if not slice: - continue - - selected.append(min(slice, key=lambda m: m['delta'])) - - logging.debug('selected %d/%d media files' % (len(selected), len(media_list))) - - return selected - - def make_movie(pictures): - global _timelapse_process - - cmd = 'rm -f %(tmp_filename)s;' - cmd += 'cat %(jpegs)s | ffmpeg -framerate %(framerate)s -f image2pipe -vcodec mjpeg -i - -vcodec mpeg4 -b:v %(bitrate)s -qscale:v 0.1 -f avi %(tmp_filename)s' - - bitrate = 9999999 - - cmd = cmd % { - 'tmp_filename': tmp_filename, - 'jpegs': ' '.join((('"' + p['path'] + '"') for p in pictures)), - 'framerate': framerate, - 'bitrate': bitrate - } - - logging.debug('executing "%s"' % cmd) - - _timelapse_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) - _timelapse_process.progress = 0.01 # 1% - - # make subprocess stdout pipe non-blocking - fd = _timelapse_process.stdout.fileno() - fl = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) - - poll_movie_process(pictures) - - def poll_movie_process(pictures): - global _timelapse_process - global _timelapse_data - - ioloop = tornado.ioloop.IOLoop.instance() - if _timelapse_process.poll() is None: # not finished yet - ioloop.add_timeout(datetime.timedelta(seconds=0.5), functools.partial(poll_movie_process, pictures)) - - try: - output = _timelapse_process.stdout.read() - - except IOError as e: - if e.errno == errno.EAGAIN: - output = '' - - else: - raise - - frame_index = re.findall('frame=\s*(\d+)', output) - try: - frame_index = int(frame_index[-1]) - - except (IndexError, ValueError): - return - - _timelapse_process.progress = max(0.01, float(frame_index) / len(pictures)) - - logging.debug('timelapse progress: %s' % int(100 * _timelapse_process.progress)) - - else: # finished - exit_code = _timelapse_process.poll() - _timelapse_process = None - - if exit_code != 0: - logging.error('ffmpeg process failed') - _timelapse_data = None - - try: - os.remove(tmp_filename) - - except: - pass - - else: - logging.debug('reading timelapse movie file "%s" into memory' % tmp_filename) - - try: - with open(tmp_filename, mode='r') as f: - _timelapse_data = f.read() - - logging.debug('timelapse movie process has returned %d bytes' % len(_timelapse_data)) - - except Exception as e: - logging.error('failed to read timelapse movie file "%s": %s' % (tmp_filename, e)) - - finally: - try: - os.remove(tmp_filename) - - except: - pass - - poll_media_list_process() - - -def check_timelapse_movie(): - if _timelapse_process: - if ((hasattr(_timelapse_process, 'poll') and _timelapse_process.poll() is None) or - (hasattr(_timelapse_process, 'is_alive') and _timelapse_process.is_alive())): - - return {'progress': _timelapse_process.progress, 'data': None} - - else: - return {'progress': _timelapse_process.progress, 'data': _timelapse_data} - - else: - return {'progress': -1, 'data': _timelapse_data} - - -def get_media_preview(camera_config, path, media_type, width, height): - target_dir = camera_config.get('target_dir') - full_path = os.path.join(target_dir, path) - - if media_type == 'movie': - if not os.path.exists(full_path + '.thumb'): - if not make_movie_preview(camera_config, full_path): - return None - - full_path += '.thumb' - - try: - with open(full_path) as f: - content = f.read() - - except Exception as e: - logging.error('failed to read file %(path)s: %(msg)s' % { - 'path': full_path, 'msg': unicode(e)}) - - return None - - if width is height is None: - return content - - sio = StringIO.StringIO(content) - image = Image.open(sio) - width = width and int(width) or image.size[0] - height = height and int(height) or image.size[1] - - image.thumbnail((width, height), Image.LINEAR) - - sio = StringIO.StringIO() - image.save(sio, format='JPEG') - - return sio.getvalue() - - -def del_media_content(camera_config, path, media_type): - target_dir = camera_config.get('target_dir') - - full_path = os.path.join(target_dir, path) - - try: - # remove the file itself - os.remove(full_path) - - # remove the parent directories if empty or contains only thumb files - dir_path = os.path.dirname(full_path) - listing = os.listdir(dir_path) - thumbs = [l for l in listing if l.endswith('.thumb')] - - if len(listing) == len(thumbs): # only thumbs - for p in thumbs: - os.remove(os.path.join(dir_path, p)) - - if not listing or len(listing) == len(thumbs): - logging.debug('removing empty directory %(path)s...' % {'path': dir_path}) - os.removedirs(dir_path) - - except Exception as e: - logging.error('failed to remove file %(path)s: %(msg)s' % { - 'path': full_path, 'msg': unicode(e)}) - - raise - - -def del_media_group(camera_config, group, media_type): - if media_type == 'picture': - exts = _PICTURE_EXTS - - elif media_type == 'movie': - exts = _MOVIE_EXTS - - target_dir = camera_config.get('target_dir') - full_path = os.path.join(target_dir, group) - - mf = _list_media_files(target_dir, exts=exts, prefix=group) - for (path, st) in mf: # @UnusedVariable - try: - os.remove(path) - - except Exception as e: - logging.error('failed to remove file %(path)s: %(msg)s' % { - 'path': full_path, 'msg': unicode(e)}) - - raise - - # remove the group directory if empty or contains only thumb files - listing = os.listdir(full_path) - thumbs = [l for l in listing if l.endswith('.thumb')] - - if len(listing) == len(thumbs): # only thumbs - for p in thumbs: - os.remove(os.path.join(full_path, p)) - - if not listing or len(listing) == len(thumbs): - logging.debug('removing empty directory %(path)s...' % {'path': full_path}) - os.removedirs(full_path) - - -def get_current_picture(camera_config, width, height): - import mjpgclient - - jpg = mjpgclient.get_jpg(camera_config['@id']) - - if jpg is None: - return None - - if width is height is None: - return jpg # no server-side resize needed - - sio = StringIO.StringIO(jpg) - image = Image.open(sio) - - width = width and int(width) or image.size[0] - height = height and int(height) or image.size[1] - - webcam_resolution = camera_config['@webcam_resolution'] - max_width = image.size[0] * webcam_resolution / 100 - max_height = image.size[1] * webcam_resolution / 100 - - width = min(max_width, width) - height = min(max_height, height) - - if width >= image.size[0] and height >= image.size[1]: - return jpg # no enlarging of the picture on the server side - - image.thumbnail((width, height), Image.CUBIC) - - sio = StringIO.StringIO() - image.save(sio, format='JPEG') - - return sio.getvalue() - - -def get_prepared_cache(key): - return _prepared_files.pop(key, None) - - -def set_prepared_cache(data): - key = hashlib.sha1(str(time.time())).hexdigest() - - if key in _prepared_files: - logging.warn('key "%s" already present in prepared cache' % key) - - _prepared_files[key] = data - - def clear(): - if _prepared_files.pop(key, None) is not None: - logging.warn('key "%s" was still present in the prepared cache, removed' % key) - - timeout = 3600 # the user has 1 hour to download the file after creation - ioloop.IOLoop.instance().add_timeout(datetime.timedelta(seconds=timeout), clear) - - return key diff --git a/src/mjpgclient.py b/src/mjpgclient.py deleted file mode 100644 index 088a962..0000000 --- a/src/mjpgclient.py +++ /dev/null @@ -1,301 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import datetime -import errno -import logging -import re -import socket -import time - -from tornado import iostream, ioloop - -import config -import motionctl -import settings -import utils - - -class MjpgClient(iostream.IOStream): - clients = {} # dictionary of clients indexed by camera id - last_jpgs = {} # dictionary of jpg contents indexed by camera id - last_jpg_moment = {} # dictionary of moments of the last received jpeg indexed by camera id - last_access = {} # dictionary of access moments indexed by camera id - last_erroneous_close_time = 0 # helps detecting erroneous connections and restart motion - - def __init__(self, camera_id, port, username, password): - self._camera_id = camera_id - self._port = port - self._username = (username or '').encode('utf8') - self._password = (password or '').encode('utf8') - self._auth_digest_state = {} - - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) - iostream.IOStream.__init__(self, s) - - self.set_close_callback(self.on_close) - - def connect(self): - iostream.IOStream.connect(self, ('localhost', self._port), self._on_connect) - MjpgClient.clients[self._camera_id] = self - - logging.debug('mjpg client for camera %(camera_id)s connecting on port %(port)s...' % { - 'port': self._port, 'camera_id': self._camera_id}) - - def on_close(self): - logging.debug('connection closed for mjpg client for camera %(camera_id)s on port %(port)s' % { - 'port': self._port, 'camera_id': self._camera_id}) - - if MjpgClient.clients.pop(self._camera_id, None): - MjpgClient.last_access.pop(self._camera_id, None) - MjpgClient.last_jpg_moment.pop(self._camera_id, None) - - logging.debug('mjpg client for camera %(camera_id)s on port %(port)s removed' % { - 'port': self._port, 'camera_id': self._camera_id}) - - if getattr(self, 'error', None) and self.error.errno != errno.ECONNREFUSED: - now = time.time() - if now - MjpgClient.last_erroneous_close_time < settings.MJPG_CLIENT_TIMEOUT: - logging.error('connection problem detected for mjpg client for camera %(camera_id)s on port %(port)s' % { - 'port': self._port, 'camera_id': self._camera_id}) - - motionctl.stop(invalidate=True) # this will close all the mjpg clients - motionctl.start(deferred=True) - - MjpgClient.last_erroneous_close_time = now - - def _check_error(self): - if self.socket is None: - logging.warning('mjpg client connection for camera %(camera_id)s on port %(port)s is closed' % { - 'port': self._port, 'camera_id': self._camera_id}) - - self.close() - - return True - - error = getattr(self, 'error', None) - if (error is None) or (getattr(error, 'errno', None) == 0): # error could also be ESUCCESS for some reason - return False - - self._error(error) - - return True - - def _error(self, error): - logging.error('mjpg client for camera %(camera_id)s on port %(port)s error: %(msg)s' % { - 'port': self._port, 'camera_id': self._camera_id, 'msg': unicode(error)}) - - try: - self.close() - - except: - pass - - def _on_connect(self): - logging.debug('mjpg client for camera %(camera_id)s connected on port %(port)s' % { - 'port': self._port, 'camera_id': self._camera_id}) - - if self._username: - auth_header = utils.build_basic_header(self._username, self._password) - self.write('GET / HTTP/1.0\r\n\r\nAuthorization: %s\r\n\r\n' % auth_header) - - else: - self.write('GET / HTTP/1.0\r\n\r\n') - - self._seek_http() - - def _seek_http(self): - if self._check_error(): - return - - self.read_until_regex('HTTP/1.\d \d+ ', self._on_http) - - def _on_http(self, data): - if data.endswith('401 '): - self._seek_www_authenticate() - - else: # no authorization required, skip to content length - self._seek_content_length() - - def _seek_www_authenticate(self): - if self._check_error(): - return - - self.read_until('WWW-Authenticate:', self._on_before_www_authenticate) - - def _on_before_www_authenticate(self, data): - if self._check_error(): - return - - self.read_until('\r\n', self._on_www_authenticate) - - def _on_www_authenticate(self, data): - if self._check_error(): - return - - m = re.match('Basic\s*realm="([a-zA-Z0-9\-\s]+)"', data.strip()) - if m: - logging.debug('mjpgclient: using basic authentication') - - auth_header = utils.build_basic_header(self._username, self._password) - self.write('GET / HTTP/1.0\r\n\r\nAuthorization: %s\r\n\r\n' % auth_header) - self._seek_http() - - return - - m = re.match('Digest\s*realm="([a-zA-Z0-9\-\s]+)",\s*nonce="([a-zA-Z0-9]+)"', data.strip()) - if m: - logging.debug('mjpgclient: using digest authentication') - - realm, nonce = m.groups() - self._auth_digest_state['realm'] = realm - self._auth_digest_state['nonce'] = nonce - - auth_header = utils.build_digest_header('GET', '/', self._username, self._password, self._auth_digest_state) - self.write('GET / HTTP/1.0\r\n\r\nAuthorization: %s\r\n\r\n' % auth_header) - self._seek_http() - - return - - logging.error('mjpgclient: unknown authentication header: "%s"' % data) - self._seek_content_length() - - def _seek_content_length(self): - if self._check_error(): - return - - self.read_until('Content-Length:', self._on_before_content_length) - - def _on_before_content_length(self, data): - if self._check_error(): - return - - self.read_until('\r\n\r\n', self._on_content_length) - - def _on_content_length(self, data): - if self._check_error(): - return - - matches = re.findall('(\d+)', data) - if not matches: - self._error('could not find content length in mjpg header line "%(header)s"' % { - 'header': data}) - - return - - length = int(matches[0]) - - self.read_bytes(length, self._on_jpg) - - def _on_jpg(self, data): - MjpgClient.last_jpgs[self._camera_id] = data - MjpgClient.last_jpg_moment[self._camera_id] = datetime.datetime.utcnow() - self._seek_content_length() - - -def _garbage_collector(): - logging.debug('running garbage collector for mjpg clients...') - - now = datetime.datetime.utcnow() - for client in MjpgClient.clients.values(): - camera_id = client._camera_id - port = client._port - - # check for last jpg moment timeout - last_jpg_moment = MjpgClient.last_jpg_moment.get(camera_id) - if last_jpg_moment is None: - MjpgClient.last_jpg_moment[camera_id] = now - - continue - - if client.closed(): - continue - - delta = now - last_jpg_moment - delta = delta.days * 86400 + delta.seconds - - if delta > settings.MJPG_CLIENT_TIMEOUT: - logging.error('mjpg client timed out receiving data for camera %(camera_id)s on port %(port)s' % { - 'camera_id': camera_id, 'port': port}) - - motionctl.stop(invalidate=True) # this will close all the mjpg clients - motionctl.start(deferred=True) - - break - - # check for last access timeout - last_access = MjpgClient.last_access.get(camera_id) - if last_access is None: - continue - - delta = now - last_access - delta = delta.days * 86400 + delta.seconds - - if settings.MJPG_CLIENT_IDLE_TIMEOUT and delta > settings.MJPG_CLIENT_IDLE_TIMEOUT: - logging.debug('mjpg client for camera %(camera_id)s on port %(port)s has been idle for %(timeout)s seconds, removing it' % { - 'camera_id': camera_id, 'port': port, 'timeout': settings.MJPG_CLIENT_IDLE_TIMEOUT}) - - client.close() - - continue - - io_loop = ioloop.IOLoop.instance() - io_loop.add_timeout(datetime.timedelta(seconds=settings.MJPG_CLIENT_TIMEOUT), _garbage_collector) - - -def get_jpg(camera_id): - if camera_id not in MjpgClient.clients: - # mjpg client not started yet for this camera - - logging.debug('creating mjpg client for camera %(camera_id)s' % { - 'camera_id': camera_id}) - - camera_config = config.get_camera(camera_id) - if not camera_config['@enabled'] or not utils.local_motion_camera(camera_config): - logging.error('could not start mjpg client for camera id %(camera_id)s: not enabled or not local' % { - 'camera_id': camera_id}) - - return None - - port = camera_config['stream_port'] - username, password = None, None - if camera_config.get('stream_auth_method') > 0: - username, password = camera_config.get('stream_authentication', ':').split(':') - - client = MjpgClient(camera_id, port, username, password) - client.connect() - - MjpgClient.last_access[camera_id] = datetime.datetime.utcnow() - - return MjpgClient.last_jpgs.get(camera_id) - - -def close_all(invalidate=False): - for client in MjpgClient.clients.values(): - client.close() - - if invalidate: - MjpgClient.clients = {} - MjpgClient.last_jpgs = {} - MjpgClient.last_jpg_moment = {} - MjpgClient.last_access = {} - MjpgClient.last_erroneous_close_time = 0 - - -# schedule the garbage collector -io_loop = ioloop.IOLoop.instance() -io_loop.add_timeout(datetime.timedelta(seconds=settings.MJPG_CLIENT_TIMEOUT), _garbage_collector) diff --git a/src/motionctl.py b/src/motionctl.py deleted file mode 100644 index df27ae6..0000000 --- a/src/motionctl.py +++ /dev/null @@ -1,332 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import errno -import logging -import os.path -import re -import signal -import subprocess -import time - -from tornado.httpclient import HTTPClient, AsyncHTTPClient, HTTPRequest -from tornado.ioloop import IOLoop - -import config -import mjpgclient -import powerctl -import settings -import utils - - -_started = False -_motion_binary_cache = None -_motion_detected = {} - - -def find_motion(): - global _motion_binary_cache - if _motion_binary_cache: - return _motion_binary_cache - - if settings.MOTION_BINARY: - if os.path.exists(settings.MOTION_BINARY): - binary = settings.MOTION_BINARY - - else: - return None - - else: # autodetect motion binary path - try: - binary = subprocess.check_output('which motion', shell=True).strip() - - except subprocess.CalledProcessError: # not found - return None - - try: - help = subprocess.check_output(binary + ' -h || true', shell=True) - - except subprocess.CalledProcessError: # not found - return None - - result = re.findall('^motion Version ([^,]+)', help) - version = result and result[0] or '' - - _motion_binary_cache = (binary, version) - - return _motion_binary_cache - - -def _disable_initial_motion_detection(): - for camera_id in config.get_camera_ids(): - camera_config = config.get_camera(camera_id) - if not utils.local_motion_camera(camera_config): - continue - - if not camera_config['@motion_detection']: - logging.debug('motion detection disabled by config for camera with id %s' % camera_id) - set_motion_detection(camera_id, False) - - -def start(deferred=False): - if deferred: - return IOLoop.instance().add_callback(start, deferred=False) - - global _started - - _started = True - - enabled_local_motion_cameras = config.get_enabled_local_motion_cameras() - if running() or not enabled_local_motion_cameras: - return - - logging.debug('starting motion') - - program = find_motion() - if not program: - raise Exception('motion executable could not be found') - - program, version = program # @UnusedVariable - - logging.debug('using motion binary "%s"' % program) - - motion_config_path = os.path.join(settings.CONF_PATH, 'motion.conf') - motion_log_path = os.path.join(settings.LOG_PATH, 'motion.log') - motion_pid_path = os.path.join(settings.RUN_PATH, 'motion.pid') - - args = [program, - '-c', motion_config_path, - '-n', - '-d'] - - if settings.LOG_LEVEL == logging.DEBUG: - args.append('9') - - else: - args.append('1') - - log_file = open(motion_log_path, 'w') - - process = subprocess.Popen(args, stdout=log_file, stderr=log_file, close_fds=True, cwd=settings.CONF_PATH) - - # wait 2 seconds to see that the process has successfully started - for i in xrange(20): # @UnusedVariable - time.sleep(0.1) - exit_code = process.poll() - if exit_code is not None and exit_code != 0: - raise Exception('motion failed to start') - - pid = process.pid - - # write the pid to file - with open(motion_pid_path, 'w') as f: - f.write(str(pid) + '\n') - - _disable_initial_motion_detection() - - # if mjpg client idle timeout is disabled, create mjpg clients for all cameras by default - if not settings.MJPG_CLIENT_IDLE_TIMEOUT: - logging.debug('creating default mjpg clients for local cameras') - for camera in enabled_local_motion_cameras: - mjpgclient.get_jpg(camera['@id']) - - -def stop(invalidate=False): - global _started - - _started = False - - if not running(): - return - - logging.debug('stopping motion') - - mjpgclient.close_all(invalidate=invalidate) - - pid = _get_pid() - if pid is not None: - try: - # send the TERM signal once - os.kill(pid, signal.SIGTERM) - - # wait 5 seconds for the process to exit - for i in xrange(50): # @UnusedVariable - os.waitpid(pid, os.WNOHANG) - time.sleep(0.1) - - # send the KILL signal once - os.kill(pid, signal.SIGKILL) - - # wait 2 seconds for the process to exit - for i in xrange(20): # @UnusedVariable - time.sleep(0.1) - os.waitpid(pid, os.WNOHANG) - - # the process still did not exit - if settings.ENABLE_REBOOT: - logging.error('could not terminate the motion process') - powerctl.reboot() - - else: - raise Exception('could not terminate the motion process') - - except OSError as e: - if e.errno not in (errno.ESRCH, errno.ECHILD): - raise - - -def running(): - pid = _get_pid() - if pid is None: - return False - - try: - os.waitpid(pid, os.WNOHANG) - os.kill(pid, 0) - - # the process is running - return True - - except OSError as e: - if e.errno not in (errno.ESRCH, errno.ECHILD): - raise - - return False - - -def started(): - return _started - - -def get_motion_detection(camera_id): - thread_id = camera_id_to_thread_id(camera_id) - if thread_id is None: - return logging.error('could not find thread id for camera with id %s' % camera_id) - - url = 'http://127.0.0.1:7999/%(id)s/detection/status' % {'id': thread_id} - - request = HTTPRequest(url, connect_timeout=5, request_timeout=5) - http_client = HTTPClient() - try: - response = http_client.fetch(request) - if response.error: - raise response.error - - except Exception as e: - logging.error('failed to get motion detection status for camera with id %(id)s: %(msg)s' % { - 'id': camera_id, - 'msg': unicode(e)}) - - return None - - enabled = bool(response.body.lower().count('active')) - - logging.debug('motion detection is %(what)s for camera with id %(id)s' % { - 'what': ['disabled', 'enabled'][enabled], - 'id': camera_id}) - - return enabled - - -def set_motion_detection(camera_id, enabled): - thread_id = camera_id_to_thread_id(camera_id) - if thread_id is None: - return logging.error('could not find thread id for camera with id %s' % camera_id) - - if not enabled: - _motion_detected[camera_id] = False - - logging.debug('%(what)s motion detection for camera with id %(id)s' % { - 'what': ['disabling', 'enabling'][enabled], - 'id': camera_id}) - - url = 'http://127.0.0.1:7999/%(id)s/detection/%(enabled)s' % { - 'id': thread_id, - 'enabled': ['pause', 'start'][enabled]} - - def on_response(response): - if response.error: - logging.error('failed to %(what)s motion detection for camera with id %(id)s: %(msg)s' % { - 'what': ['disable', 'enable'][enabled], - 'id': camera_id, - 'msg': utils.pretty_http_error(response)}) - - else: - logging.debug('successfully %(what)s motion detection for camera with id %(id)s' % { - 'what': ['disabled', 'enabled'][enabled], - 'id': camera_id}) - - request = HTTPRequest(url, connect_timeout=4, request_timeout=4) - http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) - - -def is_motion_detected(camera_id): - return _motion_detected.get(camera_id, False) - - -def set_motion_detected(camera_id, motion_detected): - if motion_detected: - logging.debug('marking motion detected for camera with id %s' % camera_id) - - else: - logging.debug('clearing motion detected for camera with id %s' % camera_id) - - _motion_detected[camera_id] = motion_detected - - -def camera_id_to_thread_id(camera_id): - # find the corresponding thread_id - # (which can be different from camera_id) - camera_ids = config.get_camera_ids() - thread_id = 0 - for cid in camera_ids: - camera_config = config.get_camera(cid) - if utils.local_motion_camera(camera_config): - thread_id += 1 - - if cid == camera_id: - return thread_id or None - - return None - - -def thread_id_to_camera_id(thread_id): - # find the corresponding camera_id - # (which can be different from thread_id) - camera_ids = config.get_camera_ids() - tid = 0 - for cid in camera_ids: - camera_config = config.get_camera(cid) - if utils.local_motion_camera(camera_config): - tid += 1 - if tid == thread_id: - return cid - - return None - - -def _get_pid(): - motion_pid_path = os.path.join(settings.RUN_PATH, 'motion.pid') - - # read the pid from file - try: - with open(motion_pid_path, 'r') as f: - return int(f.readline().strip()) - - except (IOError, ValueError): - return None diff --git a/src/ordereddict.py b/src/ordereddict.py deleted file mode 100644 index 0874135..0000000 --- a/src/ordereddict.py +++ /dev/null @@ -1,258 +0,0 @@ -# Backport of OrderedDict() class that runs on Python 2.4, 2.5, 2.6, 2.7 and pypy. -# Passes Python2.7's test suite and incorporates all the latest updates. - -try: - from thread import get_ident as _get_ident -except ImportError: - from dummy_thread import get_ident as _get_ident - -try: - from _abcoll import KeysView, ValuesView, ItemsView -except ImportError: - pass - - -class OrderedDict(dict): - 'Dictionary that remembers insertion order' - # An inherited dict maps keys to values. - # The inherited dict provides __getitem__, __len__, __contains__, and get. - # The remaining methods are order-aware. - # Big-O running times for all methods are the same as for regular dictionaries. - - # The internal self.__map dictionary maps keys to links in a doubly linked list. - # The circular doubly linked list starts and ends with a sentinel element. - # The sentinel element never gets deleted (this simplifies the algorithm). - # Each link is stored as a list of length three: [PREV, NEXT, KEY]. - - def __init__(self, *args, **kwds): - '''Initialize an ordered dictionary. Signature is the same as for - regular dictionaries, but keyword arguments are not recommended - because their insertion order is arbitrary. - - ''' - if len(args) > 1: - raise TypeError('expected at most 1 arguments, got %d' % len(args)) - try: - self.__root - except AttributeError: - self.__root = root = [] # sentinel node - root[:] = [root, root, None] - self.__map = {} - self.__update(*args, **kwds) - - def __setitem__(self, key, value, dict_setitem=dict.__setitem__): - 'od.__setitem__(i, y) <==> od[i]=y' - # Setting a new item creates a new link which goes at the end of the linked - # list, and the inherited dictionary is updated with the new key/value pair. - if key not in self: - root = self.__root - last = root[0] - last[1] = root[0] = self.__map[key] = [last, root, key] - dict_setitem(self, key, value) - - def __delitem__(self, key, dict_delitem=dict.__delitem__): - 'od.__delitem__(y) <==> del od[y]' - # Deleting an existing item uses self.__map to find the link which is - # then removed by updating the links in the predecessor and successor nodes. - dict_delitem(self, key) - link_prev, link_next, key = self.__map.pop(key) - link_prev[1] = link_next - link_next[0] = link_prev - - def __iter__(self): - 'od.__iter__() <==> iter(od)' - root = self.__root - curr = root[1] - while curr is not root: - yield curr[2] - curr = curr[1] - - def __reversed__(self): - 'od.__reversed__() <==> reversed(od)' - root = self.__root - curr = root[0] - while curr is not root: - yield curr[2] - curr = curr[0] - - def clear(self): - 'od.clear() -> None. Remove all items from od.' - try: - for node in self.__map.itervalues(): - del node[:] - root = self.__root - root[:] = [root, root, None] - self.__map.clear() - except AttributeError: - pass - dict.clear(self) - - def popitem(self, last=True): - '''od.popitem() -> (k, v), return and remove a (key, value) pair. - Pairs are returned in LIFO order if last is true or FIFO order if false. - - ''' - if not self: - raise KeyError('dictionary is empty') - root = self.__root - if last: - link = root[0] - link_prev = link[0] - link_prev[1] = root - root[0] = link_prev - else: - link = root[1] - link_next = link[1] - root[1] = link_next - link_next[0] = root - key = link[2] - del self.__map[key] - value = dict.pop(self, key) - return key, value - - # -- the following methods do not depend on the internal structure -- - - def keys(self): - 'od.keys() -> list of keys in od' - return list(self) - - def values(self): - 'od.values() -> list of values in od' - return [self[key] for key in self] - - def items(self): - 'od.items() -> list of (key, value) pairs in od' - return [(key, self[key]) for key in self] - - def iterkeys(self): - 'od.iterkeys() -> an iterator over the keys in od' - return iter(self) - - def itervalues(self): - 'od.itervalues -> an iterator over the values in od' - for k in self: - yield self[k] - - def iteritems(self): - 'od.iteritems -> an iterator over the (key, value) items in od' - for k in self: - yield (k, self[k]) - - def update(*args, **kwds): #@NoSelf - '''od.update(E, **F) -> None. Update od from dict/iterable E and F. - - If E is a dict instance, does: for k in E: od[k] = E[k] - If E has a .keys() method, does: for k in E.keys(): od[k] = E[k] - Or if E is an iterable of items, does: for k, v in E: od[k] = v - In either case, this is followed by: for k, v in F.items(): od[k] = v - - ''' - if len(args) > 2: - raise TypeError('update() takes at most 2 positional ' - 'arguments (%d given)' % (len(args),)) - elif not args: - raise TypeError('update() takes at least 1 argument (0 given)') - self = args[0] - # Make progressively weaker assumptions about "other" - other = () - if len(args) == 2: - other = args[1] - if isinstance(other, dict): - for key in other: - self[key] = other[key] - elif hasattr(other, 'keys'): - for key in other.keys(): - self[key] = other[key] - else: - for key, value in other: - self[key] = value - for key, value in kwds.items(): - self[key] = value - - __update = update # let subclasses override update without breaking __init__ - - __marker = object() - - def pop(self, key, default=__marker): - '''od.pop(k[,d]) -> v, remove specified key and return the corresponding value. - If key is not found, d is returned if given, otherwise KeyError is raised. - - ''' - if key in self: - result = self[key] - del self[key] - return result - if default is self.__marker: - raise KeyError(key) - return default - - def setdefault(self, key, default=None): - 'od.setdefault(k[,d]) -> od.get(k,d), also set od[k]=d if k not in od' - if key in self: - return self[key] - self[key] = default - return default - - def __repr__(self, _repr_running={}): - 'od.__repr__() <==> repr(od)' - call_key = id(self), _get_ident() - if call_key in _repr_running: - return '...' - _repr_running[call_key] = 1 - try: - if not self: - return '%s()' % (self.__class__.__name__,) - return '%s(%r)' % (self.__class__.__name__, self.items()) - finally: - del _repr_running[call_key] - - def __reduce__(self): - 'Return state information for pickling' - items = [[k, self[k]] for k in self] - inst_dict = vars(self).copy() - for k in vars(OrderedDict()): - inst_dict.pop(k, None) - if inst_dict: - return (self.__class__, (items,), inst_dict) - return self.__class__, (items,) - - def copy(self): - 'od.copy() -> a shallow copy of od' - return self.__class__(self) - - @classmethod - def fromkeys(cls, iterable, value=None): - '''OD.fromkeys(S[, v]) -> New ordered dictionary with keys from S - and values equal to v (which defaults to None). - - ''' - d = cls() - for key in iterable: - d[key] = value - return d - - def __eq__(self, other): - '''od.__eq__(y) <==> od==y. Comparison to another OD is order-sensitive - while comparison to a regular mapping is order-insensitive. - - ''' - if isinstance(other, OrderedDict): - return len(self)==len(other) and self.items() == other.items() - return dict.__eq__(self, other) - - def __ne__(self, other): - return not self == other - - # -- the following methods are only used in Python 2.7 -- - - def viewkeys(self): - "od.viewkeys() -> a set-like object providing a view on od's keys" - return KeysView(self) - - def viewvalues(self): - "od.viewvalues() -> an object providing a view on od's values" - return ValuesView(self) - - def viewitems(self): - "od.viewitems() -> a set-like object providing a view on od's items" - return ItemsView(self) diff --git a/src/powerctl.py b/src/powerctl.py deleted file mode 100644 index 3b82ab7..0000000 --- a/src/powerctl.py +++ /dev/null @@ -1,78 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging -import os -import subprocess - - -def _find_prog(prog): - try: - return subprocess.check_output('which %s' % prog, shell=True).strip() - - except subprocess.CalledProcessError: # not found - return None - - -def _exec_prog(prog): - logging.info('executing "%s"' % prog) - - return os.system(prog) == 0 - - -def shut_down(): - logging.info('shutting down') - - prog = _find_prog('poweroff') - if prog: - return _exec_prog(prog) - - prog = _find_prog('shutdown') - if prog: - return _exec_prog(prog + ' -h now') - - prog = _find_prog('systemctl') - if prog: - return _exec_prog(prog + ' poweroff') - - prog = _find_prog('init') - if prog: - return _exec_prog(prog + ' 0') - - return False - - -def reboot(): - logging.info('rebooting') - - prog = _find_prog('reboot') - if prog: - return _exec_prog(prog) - - prog = _find_prog('shutdown') - if prog: - return _exec_prog(prog + ' -r now') - - prog = _find_prog('systemctl') - if prog: - return _exec_prog(prog + ' reboot') - - prog = _find_prog('init') - if prog: - return _exec_prog(prog + ' 6') - - return False diff --git a/src/remote.py b/src/remote.py deleted file mode 100644 index a10599a..0000000 --- a/src/remote.py +++ /dev/null @@ -1,646 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import functools -import json -import logging -import re - -from tornado.httpclient import AsyncHTTPClient, HTTPRequest - -import settings -import utils - -_DOUBLE_SLASH_REGEX = re.compile('//+') - - -def _make_request(scheme, host, port, username, password, uri, method='GET', data=None, query=None, timeout=None): - uri = _DOUBLE_SLASH_REGEX.sub('/', uri) - url = '%(scheme)s://%(host)s%(port)s%(uri)s' % { - 'scheme': scheme, - 'host': host, - 'port': ':' + str(port) if port else '', - 'uri': uri or ''} - - query = dict(query or {}) - query['_username'] = username or '' - query['_admin'] = 'true' # always use the admin account - - if url.count('?'): - url += '&' - - else: - url += '?' - - url += '&'.join([(n + '=' + v) for (n, v) in query.iteritems()]) - url += '&_signature=' + utils.compute_signature(method, url, data, password) - - if timeout is None: - timeout = settings.REMOTE_REQUEST_TIMEOUT - - return HTTPRequest(url, method, body=data, connect_timeout=timeout, request_timeout=timeout) - - -def _callback_wrapper(callback): - @functools.wraps(callback) - def wrapper(response): - try: - decoded = json.loads(response.body) - if decoded['error'] == 'unauthorized': - response.error = 'Authentication Error' - - elif decoded['error']: - response.error = decoded['error'] - - except: - pass - - return callback(response) - - return wrapper - - -def pretty_camera_url(local_config, camera=True): - scheme = local_config.get('@scheme', local_config.get('scheme')) or 'http' - host = local_config.get('@host', local_config.get('host')) - port = local_config.get('@port', local_config.get('port')) - uri = local_config.get('@uri', local_config.get('uri')) or '' - - url = scheme + '://' + host - if port and str(port) not in ['80', '443']: - url += ':' + str(port) - - if uri: - url += uri - - if url.endswith('/'): - url = url[:-1] - - if camera: - if camera is True: - url += '/config/' + str(local_config.get('@remote_camera_id', local_config.get('remote_camera_id'))) - - else: - url += '/config/' + str(camera) - - return url - - -def _remote_params(local_config): - return ( - local_config.get('@scheme', local_config.get('scheme')) or 'http', - local_config.get('@host', local_config.get('host')), - local_config.get('@port', local_config.get('port')), - local_config.get('@username', local_config.get('username')), - local_config.get('@password', local_config.get('password')), - local_config.get('@uri', local_config.get('uri')) or '', - local_config.get('@remote_camera_id', local_config.get('remote_camera_id'))) - - -def list(local_config, callback): - scheme, host, port, username, password, uri, _ = _remote_params(local_config) - - logging.debug('listing remote cameras on %(url)s' % { - 'url': pretty_camera_url(local_config, camera=False)}) - - request = _make_request(scheme, host, port, username, password, uri + '/config/list/') - - def on_response(response): - if response.error: - logging.error('failed to list remote cameras on %(url)s: %(msg)s' % { - 'url': pretty_camera_url(local_config, camera=False), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - try: - response = json.loads(response.body) - - except Exception as e: - logging.error('failed to decode json answer from %(url)s: %(msg)s' % { - 'url': pretty_camera_url(local_config, camera=False), - 'msg': unicode(e)}) - - return callback(error=unicode(e)) - - cameras = response['cameras'] - - # filter out simple mjpeg cameras - cameras = [c for c in cameras if c['proto'] != 'mjpeg'] - - callback(cameras) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def get_config(local_config, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('getting config for remote camera %(id)s on %(url)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - request = _make_request(scheme, host, port, username, password, uri + '/config/%(id)s/get/' % {'id': camera_id}) - - def on_response(response): - if response.error: - logging.error('failed to get config for remote camera %(id)s on %(url)s: %(msg)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - try: - response = json.loads(response.body) - - except Exception as e: - logging.error('failed to decode json answer from %(url)s: %(msg)s' % { - 'url': pretty_camera_url(local_config), - 'msg': unicode(e)}) - - return callback(error=unicode(e)) - - response['host'] = host - response['port'] = port - - callback(response) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def set_config(local_config, ui_config, callback): - scheme = local_config.get('@scheme', local_config.get('scheme')) - host = local_config.get('@host', local_config.get('host')) - port = local_config.get('@port', local_config.get('port')) - username = local_config.get('@username', local_config.get('username')) - password = local_config.get('@password', local_config.get('password')) - uri = local_config.get('@uri', local_config.get('uri')) or '' - camera_id = local_config.get('@remote_camera_id', local_config.get('remote_camera_id')) - - logging.debug('setting config for remote camera %(id)s on %(url)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - ui_config = json.dumps(ui_config) - - request = _make_request(scheme, host, port, username, password, uri + '/config/%(id)s/set/' % {'id': camera_id}, method='POST', data=ui_config) - - def on_response(response): - if response.error: - logging.error('failed to set config for remote camera %(id)s on %(url)s: %(msg)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - callback() - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def set_preview(local_config, controls, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('setting preview for remote camera %(id)s on %(url)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - data = json.dumps(controls) - - request = _make_request(scheme, host, port, username, password, uri + '/config/%(id)s/set_preview/' % {'id': camera_id}, method='POST', data=data) - - def on_response(response): - if response.error: - logging.error('failed to set preview for remote camera %(id)s on %(url)s: %(msg)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - callback() - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def get_current_picture(local_config, width, height, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('getting current picture for remote camera %(id)s on %(url)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - query = {} - - if width: - query['width'] = str(width) - - if height: - query['height'] = str(height) - - request = _make_request(scheme, host, port, username, password, uri + '/picture/%(id)s/current/' % {'id': camera_id}, query=query) - - def on_response(response): - motion_detected = False - - cookies = response.headers.get('Set-Cookie') - if cookies: - cookies = cookies.split(';') - cookies = [[i.strip() for i in c.split('=')] for c in cookies] - cookies = dict([c for c in cookies if len(c) == 2]) - motion_detected = cookies.get('motion_detected_' + str(camera_id)) == 'true' - - if response.error: - logging.error('failed to get current picture for remote camera %(id)s on %(url)s: %(msg)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - callback(motion_detected, response.body) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def list_media(local_config, media_type, prefix, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('getting media list for remote camera %(id)s on %(url)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - query = {} - if prefix is not None: - query['prefix'] = prefix - - # timeout here is 10 times larger than usual - we expect a big delay when fetching the media list - request = _make_request(scheme, host, port, username, password, uri + '/%(media_type)s/%(id)s/list/' % { - 'id': camera_id, 'media_type': media_type}, query=query, timeout=10 * settings.REMOTE_REQUEST_TIMEOUT) - - def on_response(response): - if response.error: - logging.error('failed to get media list for remote camera %(id)s on %(url)s: %(msg)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - try: - response = json.loads(response.body) - - except Exception as e: - logging.error('failed to decode json answer from %(url)s: %(msg)s' % { - 'url': pretty_camera_url(local_config), - 'msg': unicode(e)}) - - return callback(error=unicode(e)) - - return callback(response) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def get_media_content(local_config, filename, media_type, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('downloading file %(filename)s of remote camera %(id)s on %(url)s' % { - 'filename': filename, - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - uri += '/%(media_type)s/%(id)s/download/%(filename)s' % { - 'media_type': media_type, - 'id': camera_id, - 'filename': filename} - - # timeout here is 10 times larger than usual - we expect a big delay when fetching the media list - request = _make_request(scheme, host, port, username, password, uri, timeout=10 * settings.REMOTE_REQUEST_TIMEOUT) - - def on_response(response): - if response.error: - logging.error('failed to download file %(filename)s of remote camera %(id)s on %(url)s: %(msg)s' % { - 'filename': filename, - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - return callback(response.body) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def make_zipped_content(local_config, media_type, group, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('preparing zip file for group %(group)s of remote camera %(id)s on %(url)s' % { - 'group': group, - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - prepare_uri = uri + '/%(media_type)s/%(id)s/zipped/%(group)s/' % { - 'media_type': media_type, - 'id': camera_id, - 'group': group} - - # timeout here is 100 times larger than usual - we expect a big delay - request = _make_request(scheme, host, port, username, password, prepare_uri, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT) - - def on_response(response): - if response.error: - logging.error('failed to prepare zip file for group %(group)s of remote camera %(id)s on %(url)s: %(msg)s' % { - 'group': group, - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - try: - key = json.loads(response.body)['key'] - - except Exception as e: - logging.error('failed to decode json answer from %(url)s: %(msg)s' % { - 'url': pretty_camera_url(local_config), - 'msg': unicode(e)}) - - return callback(error=unicode(e)) - - callback({'key': key}) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def get_zipped_content(local_config, media_type, key, group, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('downloading zip file for remote camera %(id)s on %(url)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - request = _make_request(scheme, host, port, username, password, uri + '/%(media_type)s/%(id)s/zipped/%(group)s/?key=%(key)s' % { - 'media_type': media_type, - 'group': group, - 'id': camera_id, - 'key': key}, - timeout=10 * settings.REMOTE_REQUEST_TIMEOUT) - - def on_response(response): - if response.error: - logging.error('failed to download zip file for remote camera %(id)s on %(url)s: %(msg)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - callback({ - 'data': response.body, - 'content_type': response.headers.get('Content-Type'), - 'content_disposition': response.headers.get('Content-Disposition') - }) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def make_timelapse_movie(local_config, framerate, interval, group, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('making timelapse movie for group %(group)s of remote camera %(id)s with rate %(framerate)s/%(int)s on %(url)s' % { - 'group': group, - 'id': camera_id, - 'framerate': framerate, - 'int': interval, - 'url': pretty_camera_url(local_config)}) - - uri += '/picture/%(id)s/timelapse/%(group)s/?interval=%(int)s&framerate=%(framerate)s' % { - 'id': camera_id, - 'int': interval, - 'framerate': framerate, - 'group': group} - - request = _make_request(scheme, host, port, username, password, uri, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT) - - def on_response(response): - if response.error: - logging.error('failed to make timelapse movie for group %(group)s of remote camera %(id)s with rate %(framerate)s/%(int)s on %(url)s: %(msg)s' % { - 'group': group, - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'int': interval, - 'framerate': framerate, - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - try: - response = json.loads(response.body) - - except Exception as e: - logging.error('failed to decode json answer from %(url)s: %(msg)s' % { - 'url': pretty_camera_url(local_config), - 'msg': unicode(e)}) - - return callback(error=unicode(e)) - - callback(response) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def check_timelapse_movie(local_config, group, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('checking timelapse movie status for remote camera %(id)s on %(url)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - request = _make_request(scheme, host, port, username, password, uri + '/picture/%(id)s/timelapse/%(group)s/?check=true' % { - 'id': camera_id, - 'group': group}) - - def on_response(response): - if response.error: - logging.error('failed to check timelapse movie status for remote camera %(id)s on %(url)s: %(msg)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - try: - response = json.loads(response.body) - - except Exception as e: - logging.error('failed to decode json answer from %(url)s: %(msg)s' % { - 'url': pretty_camera_url(local_config), - 'msg': unicode(e)}) - - return callback(error=unicode(e)) - - callback(response) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def get_timelapse_movie(local_config, key, group, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('downloading timelapse movie for remote camera %(id)s on %(url)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - request = _make_request(scheme, host, port, username, password, uri + '/picture/%(id)s/timelapse/%(group)s/?key=%(key)s' % { - 'id': camera_id, - 'group': group, - 'key': key}, - timeout=10 * settings.REMOTE_REQUEST_TIMEOUT) - - def on_response(response): - if response.error: - logging.error('failed to download timelapse movie for remote camera %(id)s on %(url)s: %(msg)s' % { - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - callback({ - 'data': response.body, - 'content_type': response.headers.get('Content-Type'), - 'content_disposition': response.headers.get('Content-Disposition') - }) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def get_media_preview(local_config, filename, media_type, width, height, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('getting file preview for %(filename)s of remote camera %(id)s on %(url)s' % { - 'filename': filename, - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - uri += '/%(media_type)s/%(id)s/preview/%(filename)s' % { - 'media_type': media_type, - 'id': camera_id, - 'filename': filename} - - query = {} - - if width: - query['width'] = str(width) - - if height: - query['height'] = str(height) - - request = _make_request(scheme, host, port, username, password, uri, query=query) - - def on_response(response): - if response.error: - logging.error('failed to get file preview for %(filename)s of remote camera %(id)s on %(url)s: %(msg)s' % { - 'filename': filename, - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - callback(response.body) - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def del_media_content(local_config, filename, media_type, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('deleting file %(filename)s of remote camera %(id)s on %(url)s' % { - 'filename': filename, - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - uri += '/%(media_type)s/%(id)s/delete/%(filename)s' % { - 'media_type': media_type, - 'id': camera_id, - 'filename': filename} - - request = _make_request(scheme, host, port, username, password, uri, method='POST', data='{}', timeout=settings.REMOTE_REQUEST_TIMEOUT) - - def on_response(response): - if response.error: - logging.error('failed to delete file %(filename)s of remote camera %(id)s on %(url)s: %(msg)s' % { - 'filename': filename, - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - callback() - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) - - -def del_media_group(local_config, group, media_type, callback): - scheme, host, port, username, password, uri, camera_id = _remote_params(local_config) - - logging.debug('deleting group %(group)s of remote camera %(id)s on %(url)s' % { - 'group': group, - 'id': camera_id, - 'url': pretty_camera_url(local_config)}) - - uri += '/%(media_type)s/%(id)s/delete_all/%(group)s/' % { - 'media_type': media_type, - 'id': camera_id, - 'group': group} - - request = _make_request(scheme, host, port, username, password, uri, method='POST', data='{}', timeout=settings.REMOTE_REQUEST_TIMEOUT) - - def on_response(response): - if response.error: - logging.error('failed to delete group %(group)s of remote camera %(id)s on %(url)s: %(msg)s' % { - 'group': group, - 'id': camera_id, - 'url': pretty_camera_url(local_config), - 'msg': utils.pretty_http_error(response)}) - - return callback(error=utils.pretty_http_error(response)) - - callback() - - http_client = AsyncHTTPClient() - http_client.fetch(request, _callback_wrapper(on_response)) diff --git a/src/server.py b/src/server.py deleted file mode 100644 index 623b9a4..0000000 --- a/src/server.py +++ /dev/null @@ -1,67 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from tornado.web import Application - -import handlers -import logging -import settings -import template - - -def log_request(handler): - if handler.get_status() < 400: - log_method = logging.debug - - elif handler.get_status() < 500: - log_method = logging.warning - - else: - log_method = logging.error - - request_time = 1000.0 * handler.request.request_time() - log_method("%d %s %.2fms", handler.get_status(), - handler._request_summary(), request_time) - - -application = Application( - [ - (r'^/$', handlers.MainHandler), - (r'^/config/main/(?Pset|get)/?$', handlers.ConfigHandler), - (r'^/config/(?P\d+)/(?Pget|set|rem|set_preview)/?$', handlers.ConfigHandler), - (r'^/config/(?Padd|list|backup|restore)/?$', handlers.ConfigHandler), - (r'^/picture/(?P\d+)/(?Pcurrent|list|frame)/?$', handlers.PictureHandler), - (r'^/picture/(?P\d+)/(?Pdownload|preview|delete)/(?P.+?)/?$', handlers.PictureHandler), - (r'^/picture/(?P\d+)/(?Pzipped|timelapse|delete_all)/(?P.+?)/?$', handlers.PictureHandler), - (r'^/movie/(?P\d+)/(?Plist)/?$', handlers.MovieHandler), - (r'^/movie/(?P\d+)/(?Pdownload|preview|delete)/(?P.+?)/?$', handlers.MovieHandler), - (r'^/movie/(?P\d+)/(?Pdelete_all)/(?P.+?)/?$', handlers.MovieHandler), - (r'^/_relay_event/?$', handlers.RelayEventHandler), - (r'^/log/(?P\w+)/?$', handlers.LogHandler), - (r'^/update/?$', handlers.UpdateHandler), - (r'^/power/(?Pshutdown|reboot)/?$', handlers.PowerHandler), - (r'^/version/?$', handlers.VersionHandler), - (r'^/login/?$', handlers.LoginHandler), - (r'^.*$', handlers.NotFoundHandler), - ], - debug=False, - log_function=log_request, - static_path=settings.STATIC_PATH, - static_url_prefix=settings.STATIC_URL -) - -template.add_context('STATIC_URL', settings.STATIC_URL) diff --git a/src/smbctl.py b/src/smbctl.py deleted file mode 100644 index 12e4505..0000000 --- a/src/smbctl.py +++ /dev/null @@ -1,230 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import datetime -import logging -import os -import re -import subprocess -import time - -from tornado import ioloop - -import config -import settings - - -def find_mount_cifs(): - try: - return subprocess.check_output('which mount.cifs', shell=True).strip() - - except subprocess.CalledProcessError: # not found - return None - - -def make_mount_point(server, share, username): - server = re.sub('[^a-zA-Z0-9]', '_', server).lower() - share = re.sub('[^a-zA-Z0-9]', '_', share).lower() - - if username: - username = re.sub('[^a-zA-Z0-9]', '_', username).lower() - mount_point = os.path.join(settings.SMB_MOUNT_ROOT, 'motioneye_%s_%s_%s' % (server, share, username)) - - else: - mount_point = os.path.join(settings.SMB_MOUNT_ROOT, 'motioneye_%s_%s' % (server, share)) - - return mount_point - - -def _is_motioneye_mount(mount_point): - mount_point_root = os.path.join(settings.SMB_MOUNT_ROOT, 'motioneye_') - return bool(re.match('^' + mount_point_root + '\w+$', mount_point)) - - -def list_mounts(): - logging.debug('listing smb mounts...') - - mounts = [] - with open('/proc/mounts', 'r') as f: - for line in f: - line = line.strip() - if not line: - continue - parts = line.split() - if len(parts) < 4: - continue - - target = parts[0] - mount_point = parts[1] - fstype = parts[2] - opts = ' '.join(parts[3:]) - - if fstype != 'cifs': - continue - - if not _is_motioneye_mount(mount_point): - continue - - match = re.match('//([^/]+)/(.+)', target) - if not match: - continue - - if len(match.groups()) != 2: - continue - - server, share = match.groups() - share = share.replace('\\040', ' ') # spaces are reported oddly by /proc/mounts - - match = re.search('username=([\w\s]+)', opts) - if match: - username = match.group(1) - - else: - username = None - - logging.debug('found smb mount "//%s/%s" at "%s"' % (server, share, mount_point)) - - mounts.append({ - 'server': server, - 'share': share, - 'username': username, - 'mount_point': mount_point - }) - - return mounts - - -def mount(server, share, username, password): - mount_point = make_mount_point(server, share, username) - - logging.debug('making sure mount point "%s" exists' % mount_point) - - if not os.path.exists(mount_point): - os.makedirs(mount_point) - - if username: - opts = 'username=%s,password=%s' % (username, password) - sec_types = ['ntlm', 'ntlmv2', 'none'] - - else: - opts = 'guest' - sec_types = ['none', 'ntlm', 'ntlmv2'] - - for sec in sec_types: - actual_opts = opts + ',sec=' + sec - try: - logging.debug('mounting "//%s/%s" at "%s" (sec=%s)' % (server, share, mount_point, sec)) - subprocess.check_call('mount.cifs "//%s/%s" "%s" -o "%s"' % (server, share, mount_point, actual_opts), shell=True) - break - - except subprocess.CalledProcessError: - pass - - else: - logging.error('failed to mount smb share "//%s/%s" at "%s"' % (server, share, mount_point)) - return None - - # test to see if mount point is writable - try: - path = os.path.join(mount_point, '.motioneye_' + str(int(time.time()))) - os.mkdir(path) - os.rmdir(path) - logging.debug('directory at "%s" is writable' % mount_point) - - except: - logging.error('directory at "%s" is not writable' % mount_point) - - return None - - return mount_point - - -def umount(server, share, username): - mount_point = make_mount_point(server, share, username) - logging.debug('unmounting "//%s/%s" from "%s"' % (server, share, mount_point)) - - try: - subprocess.check_call('umount "%s"' % mount_point, shell=True) - - except subprocess.CalledProcessError: - logging.error('failed to unmount smb share "//%s/%s" from "%s"' % (server, share, mount_point)) - - return False - - try: - os.rmdir(mount_point) - - except Exception as e: - logging.error('failed to remove smb mount point "%s": %s' % (mount_point, e)) - - return False - - return True - - -def update_mounts(): - network_shares = config.get_network_shares() - - mounts = list_mounts() - mounts = dict(((m['server'], m['share'], m['username'] or ''), False) for m in mounts) - - should_stop = False # indicates that motion should be stopped immediately - should_start = True # indicates that motion can be started afterwards - for network_share in network_shares: - key = (network_share['server'], network_share['share'], network_share['username'] or '') - if key in mounts: # found - mounts[key] = True - - else: # needs to be mounted - should_stop = True - if not mount(network_share['server'], network_share['share'], network_share['username'], network_share['password']): - should_start = False - - # unmount the no longer necessary mounts - for (server, share, username), required in mounts.items(): - if not required: - umount(server, share, username) - should_stop = True - - return (should_stop, should_start) - - -def umount_all(): - for mount in list_mounts(): - umount(mount['server'], mount['share'], mount['username']) - - -def _check_mounts(): - import motionctl - - logging.debug('checking SMB mounts...') - - stop, start = update_mounts() - if stop: - motionctl.stop() - - if start: - motionctl.start() - - io_loop = ioloop.IOLoop.instance() - io_loop.add_timeout(datetime.timedelta(seconds=settings.MOUNT_CHECK_INTERVAL), _check_mounts) - - -if settings.SMB_SHARES: - # schedule the mount checker - io_loop = ioloop.IOLoop.instance() - io_loop.add_timeout(datetime.timedelta(seconds=settings.MOUNT_CHECK_INTERVAL), _check_mounts) diff --git a/src/template.py b/src/template.py deleted file mode 100644 index 49ce299..0000000 --- a/src/template.py +++ /dev/null @@ -1,66 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from jinja2 import Environment, FileSystemLoader - -import settings -import utils - - -_jinja_env = None - - -def _init_jinja(): - global _jinja_env - - _jinja_env = Environment( - loader=FileSystemLoader(settings.TEMPLATE_PATH), - trim_blocks=False) - - # globals - _jinja_env.globals['settings'] = settings - - # filters - _jinja_env.filters['pretty_date_time'] = utils.pretty_date_time - _jinja_env.filters['pretty_date'] = utils.pretty_date - _jinja_env.filters['pretty_time'] = utils.pretty_time - _jinja_env.filters['pretty_duration'] = utils.pretty_duration - - -def add_template_path(path): - global _jinja_env - if _jinja_env is None: - _init_jinja() - - _jinja_env.loader.searchpath.append(path) - - -def add_context(name, value): - global _jinja_env - if _jinja_env is None: - _init_jinja() - - _jinja_env.globals[name] = value - - -def render(template_name, **context): - global _jinja_env - if _jinja_env is None: - _init_jinja() - - template = _jinja_env.get_template(template_name) - return template.render(**context) diff --git a/src/thumbnailer.py b/src/thumbnailer.py deleted file mode 100644 index c702863..0000000 --- a/src/thumbnailer.py +++ /dev/null @@ -1,86 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import datetime -import logging -import multiprocessing -import os -import signal -import tornado - -import cleanup -import mediafiles -import settings - - -_process = None - - -def start(): - # schedule the first call a bit later to improve performance at startup - ioloop = tornado.ioloop.IOLoop.instance() - ioloop.add_timeout(datetime.timedelta(seconds=30), _run_process) - - -def stop(): - global _process - - if not running(): - _process = None - return - - if _process.is_alive(): - _process.join(timeout=10) - - if _process.is_alive(): - logging.error('thumbnailer process did not finish in time, killing it...') - os.kill(_process.pid, signal.SIGKILL) - - _process = None - - -def running(): - return _process is not None and _process.is_alive() - - -def _run_process(): - global _process - - # schedule the next call - ioloop = tornado.ioloop.IOLoop.instance() - ioloop.add_timeout(datetime.timedelta(seconds=settings.THUMBNAILER_INTERVAL), _run_process) - - if not running() and not cleanup.running(): # check that the previous process has finished and that cleanup is not running - logging.debug('running thumbnailer process...') - - _process = multiprocessing.Process(target=_do_next_movie_thumbail) - _process.start() - - -def _do_next_movie_thumbail(): - # this will be executed in a separate subprocess - - # ignore the terminate and interrupt signals in this subprocess - signal.signal(signal.SIGINT, signal.SIG_IGN) - signal.signal(signal.SIGTERM, signal.SIG_IGN) - - try: - mediafiles.make_next_movie_preview() - - except Exception as e: - logging.error('failed to make movie thumbnail: %(msg)s' % { - 'msg': unicode(e)}, exc_info=True) diff --git a/src/tzctl.py b/src/tzctl.py deleted file mode 100644 index ff36ab7..0000000 --- a/src/tzctl.py +++ /dev/null @@ -1,139 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import hashlib -import logging -import os -import settings -import subprocess - -from config import additional_config - - -LOCAL_TIME_FILE = settings.LOCAL_TIME_FILE # @UndefinedVariable - - -def _get_time_zone_symlink(): - file = settings.LOCAL_TIME_FILE - if not file: - return None - - for i in xrange(8): # recursively follow the symlinks @UnusedVariable - try: - file = os.readlink(file) - - except OSError: - break - - if file and file.startswith('/usr/share/zoneinfo/'): - file = file[20:] - - else: - file = None - - time_zone = file or None - if time_zone: - logging.debug('found time zone by symlink method: %s' % time_zone) - - return time_zone - - -def _get_time_zone_md5(): - if settings.LOCAL_TIME_FILE: - return None - - try: - output = subprocess.check_output('cd /usr/share/zoneinfo; find * -type f | xargs md5sum', shell=True) - - except Exception as e: - logging.error('getting md5 of zoneinfo files failed: %s' % e) - - return None - - lines = [l for l in output.split('\n') if l] - lines = [l.split(None, 1) for l in lines] - time_zone_by_md5 = dict(lines) - - try: - with open(settings.LOCAL_TIME_FILE, 'r') as f: - data = f.read() - - except Exception as e: - logging.error('failed to read local time file: %s' % e) - - return None - - md5 = hashlib.md5(data).hexdigest() - time_zone = time_zone_by_md5.get(md5) - - if time_zone: - logging.debug('found time zone by md5 method: %s' % time_zone) - - return time_zone - - -def _get_time_zone(): - return _get_time_zone_symlink() or _get_time_zone_md5() or 'UTC' - - -def _set_time_zone(time_zone): - time_zone = time_zone or 'UTC' - - zoneinfo_file = '/usr/share/zoneinfo/' + time_zone - if not os.path.exists(zoneinfo_file): - logging.error('%s file does not exist' % zoneinfo_file) - - return False - - logging.debug('linking "%s" to "%s"' % (settings.LOCAL_TIME_FILE, zoneinfo_file)) - - try: - os.remove(settings.LOCAL_TIME_FILE) - - except: - pass # nevermind - - try: - os.symlink(zoneinfo_file, settings.LOCAL_TIME_FILE) - - return True - - except Exception as e: - logging.error('failed to link "%s" to "%s": %s' % (settings.LOCAL_TIME_FILE, zoneinfo_file, e)) - - return False - - -@additional_config -def timeZone(): - if not LOCAL_TIME_FILE: - return - - import pytz - timezones = pytz.common_timezones - - return { - 'label': 'Time Zone', - 'description': 'selecting the right timezone assures a correct timestamp displayed on pictures and movies', - 'type': 'choices', - 'choices': [(t, t) for t in timezones], - 'section': 'general', - 'advanced': True, - 'reboot': True, - 'get': _get_time_zone, - 'set': _set_time_zone - } diff --git a/src/update.py b/src/update.py deleted file mode 100644 index 0c53b25..0000000 --- a/src/update.py +++ /dev/null @@ -1,67 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging -import re - - -# versions - -def get_version(): - import motioneye - - return motioneye.VERSION - - -def get_all_versions(): - return [] - - -def compare_versions(version1, version2): - version1 = re.sub('[^0-9.]', '', version1) - version2 = re.sub('[^0-9.]', '', version2) - - version1 = [int(n) for n in version1.split('.')] - version2 = [int(n) for n in version2.split('.')] - - len1 = len(version1) - len2 = len(version2) - length = min(len1, len2) - for i in xrange(length): - p1 = version1[i] - p2 = version2[i] - - if p1 < p2: - return -1 - - elif p1 > p2: - return 1 - - if len1 < len2: - return -1 - - elif len1 > len2: - return 1 - - else: - return 0 - - -def perform_update(version): - logging.error('updating is not implemented') - - return False diff --git a/src/utils.py b/src/utils.py deleted file mode 100644 index a3fe0b8..0000000 --- a/src/utils.py +++ /dev/null @@ -1,681 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import base64 -import datetime -import functools -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 -from tornado.ioloop import IOLoop - -import settings - - -try: - from collections import OrderedDict # @UnusedImport - -except: - from ordereddict import OrderedDict # @UnusedImport @Reimport - - -_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') + ')' - - if isinstance(date_time, int): - return pretty_date_time(datetime.datetime.fromtimestamp(date_time)) - - if short: - text = u'{day} {month}, {hm}'.format( - day=date_time.day, - month=date_time.strftime('%b'), - hm=date_time.strftime('%H:%M') - ) - - else: - text = u'{day} {month} {year}, {hm}'.format( - day=date_time.day, - month=date_time.strftime('%B'), - year=date_time.year, - hm=date_time.strftime('%H:%M') - ) - - if tzinfo: - offset = tzinfo.utcoffset(datetime.datetime.utcnow()).seconds - tz = 'GMT' - if offset >= 0: - tz += '+' - - else: - tz += '-' - offset = -offset - - tz += '%.2d' % (offset / 3600) + ':%.2d' % ((offset % 3600) / 60) - - text += ' (' + tz + ')' - - return text - - -def pretty_date(date): - if date is None: - return '('+ _('never') + ')' - - if isinstance(date, int): - return pretty_date(datetime.datetime.fromtimestamp(date)) - - return u'{day} {month} {year}'.format( - day=date.day, - month=_(date.strftime('%B')), - year=date.year - ) - - -def pretty_time(time): - if time is None: - return '' - - if isinstance(time, datetime.timedelta): - hour = time.seconds / 3600 - minute = (time.seconds % 3600) / 60 - time = datetime.time(hour, minute) - - return '{hm}'.format( - hm=time.strftime('%H:%M') - ) - - -def pretty_duration(duration): - if duration is None: - duration = 0 - - if isinstance(duration, datetime.timedelta): - duration = duration.seconds + duration.days * 86400 - - if duration < 0: - negative = True - duration = -duration - - else: - negative = False - - days = int(duration / 86400) - duration %= 86400 - hours = int(duration / 3600) - duration %= 3600 - minutes = int(duration / 60) - duration %= 60 - seconds = duration - - # treat special cases - special_result = None - if days != 0 and hours == 0 and minutes == 0 and seconds == 0: - if days == 1: - special_result = str(days) + ' ' + _('day') - - elif days == 7: - special_result = '1 ' + _('week') - - elif days in [30, 31, 32]: - special_result = '1 ' + _('month') - - elif days in [365, 366]: - special_result = '1 ' + _('year') - - else: - special_result = str(days) + ' ' + _('days') - - elif days == 0 and hours != 0 and minutes == 0 and seconds == 0: - if hours == 1: - special_result = str(hours) + ' ' + _('hour') - - else: - special_result = str(hours) + ' ' + _('hours') - - elif days == 0 and hours == 0 and minutes != 0 and seconds == 0: - if minutes == 1: - special_result = str(minutes) + ' ' + _('minute') - - else: - special_result = str(minutes) + ' ' + _('minutes') - - elif days == 0 and hours == 0 and minutes == 0 and seconds != 0: - if seconds == 1: - special_result = str(seconds) + ' ' + _('second') - - else: - special_result = str(seconds) + ' ' + _('seconds') - - elif days == 0 and hours == 0 and minutes == 0 and seconds == 0: - special_result = str(0) + ' ' + _('seconds') - - if special_result: - if negative: - special_result = _('minus') + ' ' + special_result - - return special_result - - if days: - format = "{d}d{h}h{m}m" - - elif hours: - format = "{h}h{m}m" - - elif minutes: - format = "{m}m" - if seconds: - format += "{s}s" - - else: - format = "{s}s" - - if negative: - format = '-' + format - - return format.format(d=days, h=hours, m=minutes, s=seconds) - - -def pretty_size(size): - if size < 1024: # less than 1kB - size, unit = size, 'B' - - elif size < 1024 * 1024: # less than 1MB - size, unit = size / 1024.0, 'kB' - - elif size < 1024 * 1024 * 1024: # less than 1GB - size, unit = size / 1024.0 / 1024, 'MB' - - else: # greater than or equal to 1GB - size, unit = size / 1024.0 / 1024 / 1024, 'GB' - - return '%.1f %s' % (size, unit) - - -def pretty_http_error(response): - if response.code == 401 or response.error == 'Authentication Error': - return 'authentication failed' - - if not response.error: - return 'ok' - - msg = unicode(response.error) - if msg.startswith('HTTP '): - msg = msg.split(':', 1)[-1].strip() - - if msg.startswith('[Errno '): - msg = msg.split(']', 1)[-1].strip() - - if 'timeout' in msg.lower() or 'timed out' in msg.lower(): - msg = 'request timed out' - - return msg - - -def make_str(s): - if isinstance(s, str): - return s - - try: - return str(s) - - except: - try: - return unicode(s, encoding='utf8').encode('utf8') - - except: - return unicode(s).encode('utf8') - - -def make_unicode(s): - if isinstance(s, unicode): - return s - - try: - return unicode(s, encoding='utf8') - - except: - try: - return unicode(s) - - except: - return str(s).decode('utf8') - - -def get_disk_usage(path): - logging.debug('getting disk usage for path %(path)s...' % { - 'path': path}) - - try: - result = os.statvfs(path) - - except OSError as e: - logging.error('failed to execute statvfs: %(msg)s' % {'msg': unicode(e)}) - - return None - - block_size = result.f_frsize - free_blocks = result.f_bfree - total_blocks = result.f_blocks - - free_size = free_blocks * block_size - total_size = total_blocks * block_size - used_size = total_size - free_size - - return (used_size, total_size) - - -def local_motion_camera(config): - '''Tells if a camera is managed by the local motion instance.''' - return bool(config.get('videodevice') or config.get('netcam_url')) - - -def remote_camera(config): - '''Tells if a camera is managed by a remote motionEye server.''' - return config.get('@proto') == 'motioneye' - - -def v4l2_camera(config): - '''Tells if a camera is a v4l2 device managed by the local motion instance.''' - return bool(config.get('videodevice')) - - -def net_camera(config): - '''Tells if a camera is a network camera managed by the local motion instance.''' - return bool(config.get('netcam_url')) - - -def simple_mjpeg_camera(config): - '''Tells if a camera is a simple MJPEG camera not managed by any motion instance.''' - return bool(config.get('@proto') == 'mjpeg') - - -def test_mjpeg_url(data, auth_modes, allow_jpeg, callback): - data = dict(data) - data.setdefault('scheme', 'http') - data.setdefault('host', '127.0.0.1') - data.setdefault('port', '80') - 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] - status_2xx = [False] - http_11 = [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]: - content_type = header.split(':')[1].strip() - called[0] = True - - if content_type in ['image/jpg', 'image/jpeg', 'image/pjpg'] and allow_jpeg: - callback([{'id': 1, 'name': 'JPEG Network Camera', 'keep_alive': http_11[0]}]) - - elif content_type.startswith('multipart/x-mixed-replace'): - callback([{'id': 1, 'name': 'MJPEG Network Camera', 'keep_alive': http_11[0]}]) - - else: - callback(error='not a supported network camera') - - else: - # check for the status header - m = re.match('^http/1.(\d) (\d+) ', header) - if m: - if int(m.group(2)) / 100 == 2: - status_2xx[0] = True - - if m.group(1) == '1': - http_11[0] = True - - def on_response(response): - if not called[0]: - if response.code == 401 and auth_modes and data['username']: - status_2xx[0] = False - do_request(on_response) - - else: - called[0] = True - callback(error=pretty_http_error(response) if response.error else 'not a supported network camera') - - username = data['username'] or None - password = data['password'] or None - - 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] - timeout = [None] - stream = None - - def connect(): - logging.debug('testing rtsp netcam at %s' % url) - - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) - s.settimeout(settings.MJPG_CLIENT_TIMEOUT) - stream = IOStream(s) - stream.set_close_callback(on_close) - stream.connect((data['host'], int(data['port'])), on_connect) - - timeout[0] = IOLoop.instance().add_timeout(datetime.timedelta(seconds=settings.MJPG_CLIENT_TIMEOUT), - functools.partial(on_connect, _timeout=True)) - - return stream - - def on_connect(_timeout=False): - IOLoop.instance().remove_timeout(timeout[0]) - - if _timeout: - return handle_error('timeout connecting to rtsp netcam') - - if not stream: - return handle_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) - timeout[0] = IOLoop.instance().add_timeout(datetime.timedelta(seconds=settings.MJPG_CLIENT_TIMEOUT), on_rtsp) - - def on_rtsp(data): - IOLoop.instance().remove_timeout(timeout[0]) - - if data: - if data.endswith('200 '): - seek_server() - - else: - handle_error('rtsp netcam returned erroneous response: %s' % data) - - else: - handle_error('timeout waiting for rtsp netcam response') - - def seek_server(): - if check_error(): - return - - stream.read_until_regex('Server: .*', on_server) - timeout[0] = IOLoop.instance().add_timeout(datetime.timedelta(seconds=1), on_server) - - def on_server(data=None): - IOLoop.instance().remove_timeout(timeout[0]) - - if data: - identifier = re.findall('Server: (.*)', data)[0].strip() - logging.debug('rtsp netcam identifier is "%s"' % identifier) - - else: - identifier = None - logging.debug('no rtsp netcam 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 identifier: - identifier = ' ' + identifier - - else: - identifier = '' - - if 'udp' in rtsp_support: - cameras.append({'id': 'udp', 'name': '%sRTSP/UDP Camera' % identifier}) - - if 'tcp' in rtsp_support: - cameras.append({'id': 'tcp', 'name': '%sRTSP/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: - 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')] - query.sort(key=lambda q: q[0]) - # "safe" characters here are set to match the encodeURIComponent JavaScript counterpart - query = [(n, urllib.quote(v, safe="!'()*~")) for (n, v) in query] - query = '&'.join([(q[0] + '=' + q[1]) for q in query]) - parts[0] = parts[1] = '' - parts[3] = query - uri = urlparse.urlunsplit(parts) - uri = _SIGNATURE_REGEX.sub('-', uri) - key = _SIGNATURE_REGEX.sub('-', key) - - if body and body.startswith('---'): - body = None # file attachment - - body = body and _SIGNATURE_REGEX.sub('-', body.decode('utf8')) - - return hashlib.sha1('%s:%s:%s:%s' % (method, uri, body or '', key)).hexdigest().lower() - - -def build_basic_header(username, password): - return 'Basic ' + base64.encodestring('%s:%s' % (username, password)).replace('\n', '') - - -def build_digest_header(method, url, username, password, state): - realm = state['realm'] - nonce = state['nonce'] - last_nonce = state.get('last_nonce', '') - nonce_count = state.get('nonce_count', 0) - qop = state.get('qop') - algorithm = state.get('algorithm') - opaque = state.get('opaque') - - if algorithm is None: - _algorithm = 'MD5' - - else: - _algorithm = algorithm.upper() - - if _algorithm == 'MD5' or _algorithm == 'MD5-SESS': - def md5_utf8(x): - if isinstance(x, str): - x = x.encode('utf-8') - return hashlib.md5(x).hexdigest() - hash_utf8 = md5_utf8 - - elif _algorithm == 'SHA': - def sha_utf8(x): - if isinstance(x, str): - x = x.encode('utf-8') - return hashlib.sha1(x).hexdigest() - hash_utf8 = sha_utf8 - - KD = lambda s, d: hash_utf8("%s:%s" % (s, d)) - - if hash_utf8 is None: - return None - - entdig = None - p_parsed = urlparse.urlparse(url) - path = p_parsed.path - if p_parsed.query: - path += '?' + p_parsed.query - - A1 = '%s:%s:%s' % (username, realm, password) - A2 = '%s:%s' % (method, path) - - HA1 = hash_utf8(A1) - HA2 = hash_utf8(A2) - - if nonce == last_nonce: - nonce_count += 1 - - else: - nonce_count = 1 - - ncvalue = '%08x' % nonce_count - s = str(nonce_count).encode('utf-8') - s += nonce.encode('utf-8') - s += time.ctime().encode('utf-8') - s += os.urandom(8) - - cnonce = (hashlib.sha1(s).hexdigest()[:16]) - if _algorithm == 'MD5-SESS': - HA1 = hash_utf8('%s:%s:%s' % (HA1, nonce, cnonce)) - - if qop is None: - respdig = KD(HA1, "%s:%s" % (nonce, HA2)) - - elif qop == 'auth' or 'auth' in qop.split(','): - noncebit = "%s:%s:%s:%s:%s" % ( - nonce, ncvalue, cnonce, 'auth', HA2 - ) - respdig = KD(HA1, noncebit) - - else: - return None - - last_nonce = nonce - - base = 'username="%s", realm="%s", nonce="%s", uri="%s", ' \ - 'response="%s"' % (username, realm, nonce, path, respdig) - if opaque: - base += ', opaque="%s"' % opaque - if algorithm: - base += ', algorithm="%s"' % algorithm - if entdig: - base += ', digest="%s"' % entdig - if qop: - base += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) - - state['last_nonce'] = last_nonce - state['nonce_count'] = nonce_count - - return 'Digest %s' % (base) diff --git a/src/v4l2ctl.py b/src/v4l2ctl.py deleted file mode 100644 index 10637f4..0000000 --- a/src/v4l2ctl.py +++ /dev/null @@ -1,418 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import fcntl -import logging -import os.path -import re -import stat -import subprocess -import time -import utils - - -_resolutions_cache = {} -_ctrls_cache = {} -_ctrl_values_cache = {} - -_DEV_V4L_BY_ID = '/dev/v4l/by-id/' - - -def find_v4l2_ctl(): - try: - return subprocess.check_output('which v4l2-ctl', shell=True).strip() - - except subprocess.CalledProcessError: # not found - return None - - -def list_devices(): - global _resolutions_cache, _ctrls_cache, _ctrl_values_cache - - logging.debug('listing v4l2 devices...') - - try: - output = '' - started = time.time() - p = subprocess.Popen('v4l2-ctl --list-devices 2>/dev/null', shell=True, stdout=subprocess.PIPE, bufsize=1) - - fd = p.stdout.fileno() - fl = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) - - while True: - try: - data = p.stdout.read(1024) - if not data: - break - - except IOError: - data = '' - time.sleep(0.01) - - output += data - - if len(output) > 10240: - logging.warn('v4l2-ctl command returned more than 10k of output') - break - - if time.time() - started > 3: - logging.warn('v4l2-ctl command ran for more than 3 seconds') - break - - except subprocess.CalledProcessError: - logging.debug('failed to list devices (probably no devices installed)') - return [] - - try: - # try to kill the v4l2-ctl subprocess - p.kill() - - except: - pass # nevermind - - name = None - devices = [] - for line in output.split('\n'): - if line.startswith('\t'): - device = line.strip() - persistent_device = find_persistent_device(device) - devices.append((device, persistent_device, name)) - - logging.debug('found device %(name)s: %(device)s, %(persistent_device)s' % { - 'name': name, 'device': device, 'persistent_device': persistent_device}) - - else: - name = line.split('(')[0].strip() - - # clear the cache - _resolutions_cache = {} - _ctrls_cache = {} - _ctrl_values_cache = {} - - return devices - - -def list_resolutions(device): - global _resolutions_cache - - device = utils.make_str(device) - - if device in _resolutions_cache: - return _resolutions_cache[device] - - logging.debug('listing resolutions of device %(device)s...' % {'device': device}) - - resolutions = set() - output = '' - started = time.time() - p = subprocess.Popen('v4l2-ctl -d "%(device)s" --list-formats-ext | grep -vi stepwise | grep -oE "[0-9]+x[0-9]+" || true' % { - 'device': device}, shell=True, stdout=subprocess.PIPE, bufsize=1) - - fd = p.stdout.fileno() - fl = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) - - while True: - try: - data = p.stdout.read(1024) - if not data: - break - - except IOError: - data = '' - time.sleep(0.01) - - output += data - - if len(output) > 10240: - logging.warn('v4l2-ctl command returned more than 10k of output') - break - - if time.time() - started > 3: - logging.warn('v4l2-ctl command ran for more than 3 seconds') - break - - try: - # try to kill the v4l2-ctl subprocess - p.kill() - - except: - pass # nevermind - - for pair in output.split('\n'): - pair = pair.strip() - if not pair: - continue - - width, height = pair.split('x') - width = int(width) - height = int(height) - - if (width, height) in resolutions: - continue # duplicate resolution - - if width < 96 or height < 96: # some reasonable minimal values - continue - - if width % 16 or height % 16: # ignore non-modulo 16 resolutions - continue - - resolutions.add((width, height)) - - logging.debug('found resolution %(width)sx%(height)s for device %(device)s' % { - 'device': device, 'width': width, 'height': height}) - - if not resolutions: - 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 = utils.COMMON_RESOLUTIONS - - resolutions = list(sorted(resolutions, key=lambda r: (r[0], r[1]))) - _resolutions_cache[device] = resolutions - - return resolutions - - -def device_present(device): - device = utils.make_str(device) - - try: - st = os.stat(device) - return stat.S_ISCHR(st.st_mode) - - except: - return False - - -def find_persistent_device(device): - device = utils.make_str(device) - - try: - devs_by_id = os.listdir(_DEV_V4L_BY_ID) - - except OSError: - return device - - for p in devs_by_id: - p = os.path.join(_DEV_V4L_BY_ID, p) - if os.path.realpath(p) == device: - return p - - return device - - -def get_brightness(device): - return _get_ctrl(device, 'brightness') - - -def set_brightness(device, value): - _set_ctrl(device, 'brightness', value) - - -def get_contrast(device): - return _get_ctrl(device, 'contrast') - - -def set_contrast(device, value): - _set_ctrl(device, 'contrast', value) - - -def get_saturation(device): - return _get_ctrl(device, 'saturation') - - -def set_saturation(device, value): - _set_ctrl(device, 'saturation', value) - - -def get_hue(device): - return _get_ctrl(device, 'hue') - - -def set_hue(device, value): - _set_ctrl(device, 'hue', value) - - -def _get_ctrl(device, control): - global _ctrl_values_cache - - device = utils.make_str(device) - - if not device_present(device): - return None - - if device in _ctrl_values_cache and control in _ctrl_values_cache[device]: - return _ctrl_values_cache[device][control] - - controls = _list_ctrls(device) - properties = controls.get(control) - if properties is None: - logging.warn('control %(control)s not found for device %(device)s' % { - 'control': control, 'device': device}) - - return None - - value = int(properties['value']) - - # adjust the value range - if 'min' in properties and 'max' in properties: - min_value = int(properties['min']) - max_value = int(properties['max']) - - value = int(round((value - min_value) * 100.0 / (max_value - min_value))) - - else: - logging.warn('min and max values not found for control %(control)s of device %(device)s' % { - 'control': control, 'device': device}) - - logging.debug('control %(control)s of device %(device)s is %(value)s%%' % { - 'control': control, 'device': device, 'value': value}) - - return value - - -def _set_ctrl(device, control, value): - global _ctrl_values_cache - - device = utils.make_str(device) - - if not device_present(device): - return - - controls = _list_ctrls(device) - properties = controls.get(control) - if properties is None: - logging.warn('control %(control)s not found for device %(device)s' % { - 'control': control, 'device': device}) - - return - - _ctrl_values_cache.setdefault(device, {})[control] = value - - # adjust the value range - if 'min' in properties and 'max' in properties: - min_value = int(properties['min']) - max_value = int(properties['max']) - - value = int(round(min_value + value * (max_value - min_value) / 100.0)) - - else: - logging.warn('min and max values not found for control %(control)s of device %(device)s' % { - 'control': control, 'device': device}) - - logging.debug('setting control %(control)s of device %(device)s to %(value)s' % { - 'control': control, 'device': device, 'value': value}) - - output = '' - started = time.time() - p = subprocess.Popen('v4l2-ctl -d "%(device)s" --set-ctrl %(control)s=%(value)s' % { - 'device': device, 'control': control, 'value': value}, shell=True, stdout=subprocess.PIPE, bufsize=1) - - fd = p.stdout.fileno() - fl = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) - - while True: - try: - data = p.stdout.read(1024) - if not data: - break - - except IOError: - data = '' - time.sleep(0.01) - - output += data - - if len(output) > 10240: - logging.warn('v4l2-ctl command returned more than 10k of output') - break - - if time.time() - started > 3: - logging.warn('v4l2-ctl command ran for more than 3 seconds') - break - - try: - # try to kill the v4l2-ctl subprocess - p.kill() - - except: - pass # nevermind - - -def _list_ctrls(device): - global _ctrls_cache - - device = utils.make_str(device) - - if device in _ctrls_cache: - return _ctrls_cache[device] - - output = '' - started = time.time() - p = subprocess.Popen('v4l2-ctl -d "%(device)s" --list-ctrls' % { - 'device': device}, shell=True, stdout=subprocess.PIPE, bufsize=1) - - fd = p.stdout.fileno() - fl = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) - - while True: - try: - data = p.stdout.read(1024) - if not data: - break - - except IOError: - data = '' - time.sleep(0.01) - - output += data - - if len(output) > 10240: - logging.warn('v4l2-ctl command returned more than 10k of output') - break - - if time.time() - started > 3: - logging.warn('v4l2-ctl command ran for more than 3 seconds') - break - - try: - # try to kill the v4l2-ctl subprocess - p.kill() - - except: - pass # nevermind - - controls = {} - for line in output.split('\n'): - if not line: - continue - - match = re.match('^\s*(\w+)\s+\(\w+\)\s+\:\s*(.+)', line) - if not match: - continue - - (control, properties) = match.groups() - properties = dict([v.split('=', 1) for v in properties.split(' ') if v.count('=')]) - controls[control] = properties - - _ctrls_cache[device] = controls - - return controls diff --git a/src/wifictl.py b/src/wifictl.py deleted file mode 100644 index 32ec562..0000000 --- a/src/wifictl.py +++ /dev/null @@ -1,248 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging -import re -import settings - -from config import additional_config, additional_section - - -WPA_SUPPLICANT_CONF = settings.WPA_SUPPLICANT_CONF # @UndefinedVariable - - -def _get_wifi_settings(): - # will return the first configured network - - logging.debug('reading wifi settings from %s' % WPA_SUPPLICANT_CONF) - - try: - conf_file = open(WPA_SUPPLICANT_CONF, 'r') - - except Exception as e: - logging.error('could open wifi settings file %(path)s: %(msg)s' % { - 'path': WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) - - return { - 'wifiEnabled': False, - 'wifiNetworkName': '', - 'wifiNetworkKey': '' - } - - lines = conf_file.readlines() - conf_file.close() - - ssid = psk = '' - in_section = False - for line in lines: - line = line.strip() - if line.startswith('#'): - continue - - if '{' in line: - in_section = True - - elif '}' in line: - in_section = False - break - - elif in_section: - m = re.search('ssid\s*=\s*"(.*?)"', line) - if m: - ssid = m.group(1) - - m = re.search('psk\s*=\s*"(.*?)"', line) - if m: - psk = m.group(1) - - if ssid: - logging.debug('wifi is enabled (ssid = "%s")' % ssid) - - return { - 'wifiEnabled': True, - 'wifiNetworkName': ssid, - 'wifiNetworkKey': psk - } - - else: - logging.debug('wifi is disabled') - - return { - 'wifiEnabled': False, - 'wifiNetworkName': ssid, - 'wifiNetworkKey': psk - } - - -def _set_wifi_settings(s): - s.setdefault('wifiEnabled', False) - s.setdefault('wifiNetworkName', '') - s.setdefault('wifiNetworkKey', '') - - logging.debug('writing wifi settings to %s: enabled=%s, ssid="%s"' % ( - WPA_SUPPLICANT_CONF, s['wifiEnabled'], s['wifiNetworkName'])) - - enabled = s['wifiEnabled'] - ssid = s['wifiNetworkName'] - psk = s['wifiNetworkKey'] - - # will update the first configured network - try: - conf_file = open(WPA_SUPPLICANT_CONF, 'r') - - except Exception as e: - logging.error('could open wifi settings file %(path)s: %(msg)s' % { - 'path': WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) - - return - - lines = conf_file.readlines() - conf_file.close() - - in_section = False - found_ssid = False - found_psk = False - i = 0 - while i < len(lines): - line = lines[i].strip() - if line.startswith('#'): - i += 1 - continue - - if '{' in line: - in_section = True - - elif '}' in line: - in_section = False - if enabled and ssid and not found_ssid: - lines.insert(i, ' ssid="' + ssid + '"\n') - if enabled and psk and not found_psk: - lines.insert(i, ' psk="' + psk + '"\n') - - found_psk = found_ssid = True - - break - - elif in_section: - if enabled: - if re.match('ssid\s*=\s*".*?"', line): - lines[i] = ' ssid="' + ssid + '"\n' - found_ssid = True - - elif re.match('psk\s*=\s*".*?"', line): - if psk: - lines[i] = ' psk="' + psk + '"\n' - found_psk = True - - else: - lines.pop(i) - i -= 1 - - else: # wifi disabled - if re.match('ssid\s*=\s*".*?"', line) or re.match('psk\s*=\s*".*?"', line): - lines.pop(i) - i -= 1 - - i += 1 - - if enabled and not found_ssid: - lines.append('network={\n') - lines.append(' scan_ssid=1\n') - lines.append(' ssid="' + ssid + '"\n') - lines.append(' psk="' + psk + '"\n') - lines.append('}\n\n') - - try: - conf_file = open(WPA_SUPPLICANT_CONF, 'w') - - except Exception as e: - logging.error('could open wifi settings file %(path)s: %(msg)s' % { - 'path': WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) - - return - - for line in lines: - conf_file.write(line) - - conf_file.close() - - -@additional_section -def network(): - return { - 'label': 'Network', - 'description': 'configure the network connection', - 'advanced': True - } - - -@additional_config -def wifiEnabled(): - if not WPA_SUPPLICANT_CONF: - return - - return { - 'label': 'Wireless Network', - 'description': 'enable this if you want to connect to a wireless network', - 'type': 'bool', - 'section': 'network', - 'advanced': True, - 'reboot': True, - 'get': _get_wifi_settings, - 'set': _set_wifi_settings, - 'get_set_dict': True - } - - -@additional_config -def wifiNetworkName(): - if not WPA_SUPPLICANT_CONF: - return - - return { - 'label': 'Wireless Network Name', - 'description': 'the name (SSID) of your wireless network', - 'type': 'str', - 'section': 'network', - 'advanced': True, - 'required': True, - 'reboot': True, - 'depends': ['wifiEnabled'], - 'get': _get_wifi_settings, - 'set': _set_wifi_settings, - 'get_set_dict': True - } - - -@additional_config -def wifiNetworkKey(): - if not WPA_SUPPLICANT_CONF: - return - - return { - 'label': 'Wireless Network Key', - 'description': 'the key (PSK) required to connect to your wireless network', - 'type': 'pwd', - 'section': 'network', - 'advanced': True, - 'required': False, - 'reboot': True, - 'depends': ['wifiEnabled'], - 'get': _get_wifi_settings, - 'set': _set_wifi_settings, - 'get_set_dict': True - } diff --git a/src/wsswitch.py b/src/wsswitch.py deleted file mode 100644 index be65c27..0000000 --- a/src/wsswitch.py +++ /dev/null @@ -1,116 +0,0 @@ - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import datetime -import logging -import tornado - -import config -import motionctl -import utils - - -def start(): - ioloop = tornado.ioloop.IOLoop.instance() - ioloop.add_timeout(datetime.timedelta(seconds=10), _check_ws) - - -def _during_working_schedule(now, working_schedule): - parts = working_schedule.split('|') - if len(parts) < 7: - return False # invalid ws - - ws_day = parts[now.weekday()] - parts = ws_day.split('-') - if len(parts) != 2: - return False # invalid ws - - _from, to = parts - if not _from or not to: - return False # ws disabled for this day - - _from = _from.split(':') - to = to.split(':') - if len(_from) != 2 or len(to) != 2: - return False # invalid ws - - try: - from_h = int(_from[0]) - from_m = int(_from[1]) - to_h = int(to[0]) - to_m = int(to[1]) - - except ValueError: - return False # invalid ws - - if now.hour < from_h or now.hour > to_h: - return False - - if now.hour == from_h and now.minute < from_m: - return False - - if now.hour == to_h and now.minute > to_m: - return False - - return True - - -def _check_ws(): - # schedule the next call - ioloop = tornado.ioloop.IOLoop.instance() - ioloop.add_timeout(datetime.timedelta(seconds=10), _check_ws) - - if not motionctl.running(): - return - - now = datetime.datetime.now() - for camera_id in config.get_camera_ids(): - camera_config = config.get_camera(camera_id) - if not utils.local_motion_camera(camera_config): - continue - - working_schedule = camera_config.get('@working_schedule') - motion_detection = camera_config.get('@motion_detection') - working_schedule_type = camera_config.get('@working_schedule_type') or 'outside' - - if not working_schedule: # working schedule disabled, motion detection left untouched - continue - - if not motion_detection: # motion detection explicitly disabled - continue - - now_during = _during_working_schedule(now, working_schedule) - must_be_enabled = (now_during and working_schedule_type == 'during') or (not now_during and working_schedule_type == 'outside') - - currently_enabled = motionctl.get_motion_detection(camera_id) - if currently_enabled is None: # could not detect current status - logging.warn('skipping motion detection status update for camera with id %(id)s' % {'id': camera_id}) - continue - - if currently_enabled and not must_be_enabled: - logging.debug('must disable motion detection for camera with id %(id)s (%(what)s working schedule)' % { - 'id': camera_id, - 'what': working_schedule_type}) - - motionctl.set_motion_detection(camera_id, False) - - elif not currently_enabled and must_be_enabled: - logging.debug('must enable motion detection for camera with id %(id)s (%(what)s working schedule)' % { - 'id': camera_id, - 'what': working_schedule_type}) - - motionctl.set_motion_detection(camera_id, True) diff --git a/static/css/frame.css b/static/css/frame.css deleted file mode 100644 index ff0b35f..0000000 --- a/static/css/frame.css +++ /dev/null @@ -1,43 +0,0 @@ - - - /* basic */ - -body { - color: #dddddd; - background-color: #212121; -} - - - /* camera frame */ - -div.camera-frame { - position: relative; - padding: 0px; - margin: 0px; - width: 100%; - height: 100%; -} - -div.camera-container { - height: 100%; - text-align: center; - overflow: hidden; -} - -img.camera { - height: auto; - margin: auto; -} - -img.camera.error, -img.camera.loading { - height: 100% !important; -} - -div.camera-placeholder { - overflow: hidden; -} - -div.camera-progress { - cursor: default; -} diff --git a/static/css/jquery.timepicker.css b/static/css/jquery.timepicker.css deleted file mode 100755 index ad4665d..0000000 --- a/static/css/jquery.timepicker.css +++ /dev/null @@ -1,69 +0,0 @@ - -.ui-timepicker-wrapper { - overflow-y: auto; - height: 150px; - width: 6.5em; - background: #414141; - border: 1px solid #515151; - -webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2); - -moz-box-shadow:0 5px 10px rgba(0,0,0,0.2); - box-shadow:0 5px 10px rgba(0,0,0,0.2); - outline: none; - z-index: 10001; - margin: 0; -} - -.ui-timepicker-wrapper.ui-timepicker-with-duration { - width: 11em; -} - -.ui-timepicker-list { - margin: 0; - padding: 0; - list-style: none; -} - -.ui-timepicker-duration { - margin-left: 5px; color: #888; -} - -.ui-timepicker-list:hover .ui-timepicker-duration { - color: #888; -} - -.ui-timepicker-list li { - padding: 3px 0 3px 5px; - cursor: pointer; - white-space: nowrap; - color: white; - list-style: none; - font-size: 0.8em; - margin: 0; -} - -.ui-timepicker-list:hover .ui-timepicker-selected { - background: #aaa; color: black; -} - -li.ui-timepicker-selected, -.ui-timepicker-list li:hover, -.ui-timepicker-list .ui-timepicker-selected:hover { - background: #1980EC; color: #fff; -} - -li.ui-timepicker-selected .ui-timepicker-duration, -.ui-timepicker-list li:hover .ui-timepicker-duration { - color: #ccc; -} - -.ui-timepicker-list li.ui-timepicker-disabled, -.ui-timepicker-list li.ui-timepicker-disabled:hover, -.ui-timepicker-list li.ui-timepicker-selected.ui-timepicker-disabled { - color: #888; - cursor: default; -} - -.ui-timepicker-list li.ui-timepicker-disabled:hover, -.ui-timepicker-list li.ui-timepicker-selected.ui-timepicker-disabled { - background: #f2f2f2; -} diff --git a/static/css/main.css b/static/css/main.css deleted file mode 100644 index c02e29b..0000000 --- a/static/css/main.css +++ /dev/null @@ -1,1043 +0,0 @@ - - - /* basic */ - -* { - padding: 0px; - border: 0px solid black; - margin: 0px; - outline: 0px; - border-spacing: 0px; - border-collapse: separate; -} - -html { - height: 100%; -} - -body { - height: 100%; - color: #dddddd; - font-size: 22px; - background-color: #212121; -} - -select, -input[type=text], -input[type=password], -textarea { - box-sizing: border-box; -} - - - /* fonts */ - -@font-face { - font-family: 'Maven Pro'; - src: url('../fnt/mavenpro-regular-webfont.eot'); - src: url('../fnt/mavenpro-regular-webfont.eot?#iefix') format('embedded-opentype'), - url('../fnt/mavenpro-regular-webfont.woff') format('woff'), - url('../fnt/mavenpro-regular-webfont.ttf') format('truetype'), - url('../fnt/mavenpro-regular-webfont.svg#maven_proregular') format('svg'); - font-weight: normal; - font-style: normal; -} - -@font-face { - font-family: 'Maven Pro'; - src: url('../fnt/mavenpro-bold-webfont.eot'); - src: url('../fnt/mavenpro-bold-webfont.eot?#iefix') format('embedded-opentype'), - url('../fnt/mavenpro-bold-webfont.woff') format('woff'), - url('../fnt/mavenpro-bold-webfont.ttf') format('truetype'), - url('../fnt/mavenpro-bold-webfont.svg#maven_probold') format('svg'); - font-weight: bold; - font-style: normal; -} - - - /* layout */ - -html { - font-family: 'Maven Pro'; -} - -div.page, -div.header-container { - position: relative; - min-width: 320px; - width: 100%; -} - -div.page { - font-size: 1em; - transition: all 0.5s linear; - min-height: 100%; -} - -div.header { - background-color: rgba(64, 64, 64, 0.5); - box-shadow: 0px 0px 5px rgba(0,0,0,0.3); - top: 0px; - width: 100%; - height: 50px; - position: fixed; - overflow: hidden; - z-index: 10000; -} - -div.header-container { - transition: all 0.5s linear; -} - -div.footer { - position: absolute; - bottom: 5px; - width: 100%; - height: 3em; - font-size: 0.7em; - color: #aaa; - text-align: center; -} - -div.copyright-note { - border-top: 1px solid #333; - padding-top: 0.2em; - margin: 0px 15%; -} - -div.page-container { - transition: all 0.2s linear; - padding: 55px 5px 3em 2%; -} - -div.page-container.stretched { - margin-left: 40%; - padding-left: 5px; -} - - - /* icons & icon buttons */ - -div.button.settings-button { - margin: 1px; - vertical-align: middle; - background-image: url(../img/settings.svg); - width: 48px; - height: 48px; -} - -div.button.logout-button { - margin: 1px; - vertical-align: middle; - background-image: url(../img/logout.svg); - width: 48px; - height: 48px; -} - -body.admin div.logout-button { - display: none; -} - -body.admin div.settings-top-bar.closed div.logout-button { - display: inline-block; -} - -body:not(.admin) div.settings-top-bar div.logout-button { - display: none; -} - -div.button.rem-camera-button { - display: none; - margin: 1px; - vertical-align: middle; - background-image: url(../img/settings.svg); - width: 48px; - height: 48px; - background-position: -48px 0px; -} - -div.settings-top-bar.open div.button.rem-camera-button { - display: inline-block; -} - -div.logo { - float: right; - display: inline-block; - white-space: nowrap; - opacity: 0.86; -} - -span.logo { - color: white; - vertical-align: middle; - font-size: 27px; - font-weight: bold; - position: relative; - top: 3px; -} - -img.logo { - width: 36px; - height: 36px; - padding: 7px 3px; - vertical-align: middle; -} - -img.background-logo { - position: absolute; - width: 30%; - left: 35%; - top: 10em; - opacity: 0.03; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; -} - -img.main-loading-progress { - display: block; - margin: auto; - margin-top: 50px; -} - -div.add-camera-message { - text-align: center; - margin-top: 30px; -} - -div.hostname { - vertical-align: middle; - display: inline-block; - font-size: 27px; -} - - - /* settings */ - -div.settings { - background-color: #313131; - position: fixed; - z-index: 1; - top: 50px; - left: 0px; - width: 0px; - bottom: 0px; - transition: all 0.2s linear; - overflow: auto; -} - -div.settings.open { - width: 40%; - min-width: 320px; -} - -body:not(.admin) div.settings { - display: none !important; -} - -div.settings-container { - position: relative; - padding-top: 10px; - display: none; - white-space: nowrap; -} - -div.settings.open div.settings-container { - display: block; -} - -div.settings-progress { - position: absolute; - top: 0px; - width: 0px; - bottom: 0px; - left: 0px; - background-color: #313131; - opacity: 0; - transition: opacity 0.1s linear; -} - -div.settings-top-bar { - position: relative; - display: inline-block; - width: 40%; - height: 50px; -} - -div.settings-top-bar.open { - background-color: #414141; - min-width: 320px; -} - -body:not(.admin) div.settings-top-bar { - display: none !important; -} - -div.settings-top-bar.closed div.apply-button { - display: none !important; -} - -div.settings-section-title { - position: relative; - text-align: right; - background-color: rgba(100, 100, 100, 0.3); - padding: 5px 0.5em 5px 5px; -} - -a.settings-section-title { -} - -table.settings { - width: 100%; - padding: 0.5em 0.5em 1em 0.5em; -} - -td.settings-item-label { - width: 50%; - text-align: right; - padding-right: 5px; -} - -td.settings-item-value { - width: 50%; - text-align: left; - padding-left: 5px; -} - -span.settings-item-label { - font-size: 0.9em; -} - -span.settings-item-unit { - font-size: 0.6em; - padding: 0px 0.2em; -} - -div.settings-item-separator { - height: 1px; - border-top: 1px solid #414141; - margin: 0.5em 1em; -} - -#cameraSelect { - display: none; - padding: 4px 1.5em 4px 4px; - vertical-align: middle; - font-size: 1.1em; - width: auto; - max-width: 35%; -} - -div.apply-button { - position: relative; - display: none; - opacity: 0; - float: right; - width: 4em; - height: 30px; - line-height: 30px; - text-align: center; - margin: 10px; - color: white; - background-color: #FF6F00; - border-radius: 3px; - transition: all 0.1s linear; -} - -div.apply-button:HOVER { - background-color: #FF7D19; -} - -div.apply-button:ACTIVE { - background-color: #F06800; -} - -div.apply-button.progress { - background-color: #FF6F00; -} - -img.apply-progress { - margin-top: 3px; -} - -div.normal-button { - position: relative; - height: 1.5em; - line-height: 1.5em; - text-align: center; - margin: 2px 0px; - color: white; - font-size: 0.9em; - border-radius: 3px; - transition: all 0.1s linear; - width: 7em; -} - -div.update-button, -div.backup-button, -div.restore-button { - background: #317CAD; -} - -div.shut-down-button, -div.reboot-button { - background: #c0392b; -} - -div.update-button:HOVER, -div.backup-button:HOVER, -div.restore-button:HOVER { - background: #3498db; -} - -div.shut-down-button:HOVER, -div.reboot-button:HOVER { - background: #D43F2F; -} - -div.update-button:ACTIVE, -div.backup-button:ACTIVE, -div.restore-button:ACTIVE { - background: #317CAD; -} - -div.shut-down-button:ACTIVE, -div.reboot-button:ACTIVE { - background: #B03427; -} - -div.settings-top-bar.open #cameraSelect { - display: inline; -} - -div.settings-top-bar.open div.logout-button { - display: none; -} - -div.check-box.section { - margin: 0px; - float: left; -} - -div.check-box.section div.check-box-button { - background-color: #515151; -} - -div.check-box.on.section div.check-box-button { - background-color: #317CAD; -} - -div.check-box.on.section:FOCUS div.check-box-button, -div.check-box.on.section:HOVER div.check-box-button { - background-color: #3498db; -} - -input[type=text].working-schedule.number { - width: 50px; -} - -#diskUsageProgressBar { - width: 90%; -} - -div.hidden, -tr.hidden { - display: none !important; -} - -span.help-mark { - display: inline-block; - visibility: hidden; - text-align: center; - background-color: #414141; - color: #3498db; - font-size: 0.75em; - font-family: monospace; - width: 1.2em; - height: 1.2em; - border-radius: 100em; - cursor: pointer; - vertical-align: middle; - position: relative; - top: -0.1em; -} - -div.settings-section-title > span.help-mark { - background-color: #515151; -} - -div.settings-section-title:HOVER > span.help-mark, -tr:HOVER span.help-mark { - visibility: visible; -} - -span.minimize { - display: inline-block; - background-image: url(../img/combo-box-arrow.svg); - background-size: cover; - width: 0.8em; - height: 0.8em; - cursor: pointer; - vertical-align: middle; - position: relative; - top: -0.1em; - transition: transform 0.1s linear; - -webkit-transform: rotate(90deg); - -moz-transform: rotate(90deg); - -ms-transform: rotate(90deg); - -o-transform: rotate(90deg); - transform: rotate(90deg); -} - -span.minimize.open { - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -ms-transform: rotate(0deg); - -o-transform: rotate(0deg); - transform: rotate(0deg); -} - - - /* dialogs */ - -table.login-dialog { - margin: auto; - font-size: 22px; /* always bigger, regardless of screen size */ -} - -table.add-camera-dialog { - margin: auto; -} - -table.add-camera-dialog select, -table.add-camera-dialog input[type=text], -table.add-camera-dialog input[type=password] { - width: 17em; -} - -span#cameraMsgLabel { - color: red; - font-size: 0.7em; -} - -div#addCameraInfo { - font-size: 0.7em; - max-width: 33em; -} - -div.media-dialog { -} - -div.media-dialog-groups { - float: left; - width: 11em; - text-align: center; - overflow: auto; - white-space: nowrap; -} - -div.media-dialog-groups.small-screen { - float: none; -} - -div.media-dialog-group-button { - height: 1.5em; - width: 10.5em; - box-sizing: border-box; - line-height: 1.5em; - text-align: center; - margin: 0em 0.2em 0.2em 0.2em; - padding: 0px 0.5em; - background-color: #414141; - color: #3498db; - border-radius: 3px; - transition: all 0.1s linear; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; -} - -div.media-dialog-groups.small-screen div.media-dialog-group-button { - display: inline-block; -} - -div.media-dialog-group-button:HOVER { - background-color: #515151; -} - -div.media-dialog-group-button:ACTIVE { - background-color: #414141; -} - -div.media-dialog-group-button.current { - background-color: #317CAD; - color: white; -} - -div.media-dialog-group-button.current:HOVER { - background-color: #3498db; -} - -div.media-dialog-group-button.current:ACTIVE { - background-color: #317CAD; -} - -div.media-dialog-list { - overflow: auto; - position: relative; -} - -div.media-list-group-title { - background-color: #313131; - font-size: 1.3em; - font-weight: bold; - text-align: center; - padding: 1em 0px 0.2em 0px; -} - -img.media-list-progress { - position: relative; - top: 35%; - display: block; - margin: auto; -} - -div.media-list-entry { - height: 4em; - background-color: #414141; - border-bottom: 1px solid #313131; - cursor: pointer; - transition: background-color 0.1s linear; -} - -div.media-list-entry:HOVER { - background-color: #494949; -} - -div.media-list-entry:ACTIVE { - background-color: #3b3b3b; -} - -img.media-list-preview { - float: left; - height: 3em; - margin: 0.45em; - border: 1px solid #212121; - box-shadow: 1px 1px 6px rgba(0,0,0,0.3); -} - -div.media-list-entry-name { - font-weight: bold; - font-size: 1.3em; - padding: 0.4em 0em; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -div.media-list-entry-details { - font-size: 1em; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -div.media-list-entry-details span.details-moment-short { - display: none; -} - -div.media-list-download-button, -div.media-list-delete-button { - float: right; - clear: right; - height: 1.5em; - width: 5em; - line-height: 1.5em; - text-align: center; - margin: 0px 0.5em; - padding: 0px 0.5em; - color: white; - border-radius: 3px; - transition: all 0.1s linear; -} - -div.media-list-download-button { - margin-top: 0.4em; - margin-bottom: 0.1em; - background: #317CAD; -} - -div.media-list-download-button:HOVER { - background-color: #3498db; -} - -div.media-list-download-button:ACTIVE { - background-color: #317CAD; -} - -div.media-list-delete-button { - margin-top: 0.1em; - margin-bottom: 0.4em; - background: #c0392b; -} - -div.media-list-delete-button:HOVER { - background-color: #D43F2F; -} - -div.media-list-delete-button:ACTIVE { - background-color: #B03427; -} - -div.media-dialog-buttons { - margin: 0.5em 0px 0px 0px; - text-align: center; -} - -div.media-dialog-button { - cursor: pointer; - display: inline-block; - height: 1.5em; - line-height: 1.5em; - text-align: center; - padding: 0px 0.5em; - margin: 0px 5px 0px 0px; - color: white; - background-color: #317CAD; - border-radius: 3px; - transition: all 0.1s linear; -} - -div.media-dialog-button:HOVER { - background-color: #3498db; -} - -div.media-dialog-button:ACTIVE { - background-color: #317CAD; -} - -div.picture-dialog-content { - position: relative; - text-align: center; - min-height: 100px; -} - -div.picture-dialog-prev-arrow, -div.picture-dialog-next-arrow { - position: absolute; - top: 45%; - background-color: rgba(0, 0, 0, 0.6); - background-image: url(../img/arrows.svg); - background-size: cover; - width: 3em; - height: 3em; - border-radius: 0.3em; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - cursor: pointer; -} - -div.picture-dialog-prev-arrow { - left: 1em; -} - -div.picture-dialog-next-arrow { - right: 1em; - background-position: -100% 0%; -} - -img.picture-dialog-content { - border: 1px solid #292929; -} - -img.picture-dialog-progress { - position: absolute; - background-color: #313131; - padding: 10px; - border-radius: 10px; - opacity: 0.7; -} - -table.timelapse-dialog select { - width: 10em; -} - -td.timelapse-warning { - font-size: 80%; - display: none; - color: red; - max-width: 20em; - text-align: center; - white-space: normal; - padding-bottom: 1em; -} - -div.media-dialog-delete-all-button { - margin-top: 0.1em; - margin-bottom: 0.4em; - background: #c0392b; -} - -div.media-dialog-delete-all-button:HOVER { - background-color: #D43F2F; -} - -div.media-dialog-delete-all-button:ACTIVE { - background-color: #B03427; -} - -td.login-dialog-error { - color: red; - display: none; -} - - - /* camera frames */ - -div.camera-list { - text-align: center; -} - -div.camera-frame, -div.camera-frame-place-holder { - position: relative; - width: 32%; - text-align: left; - background-color: #313131; - display: inline-block; - padding: 0px 3px 3px 3px; - border-radius: 3px; - transition: all 0.2s, opacity 0s; - margin: 2px; - opacity: 0; - vertical-align: top; -} - -div.camera-frame:only-child, -div.camera-frame-place-holder:only-child { - width: 48%; -} - -div.camera-frame-place-holder { - visibility: hidden; -} - -div.camera-frame.motion-detected { - background-color: #712727; -} - -div.modal-container div.camera-frame { - width: auto; - padding: 0px; - margin: -7px; - background-color: #414141; -} - -div.camera-frame:HOVER { - background-color: #414141; -} - -div.camera-frame.motion-detected:HOVER { - background-color: #8B3636; -} - -div.camera-top-bar { - padding: 3px 0px; - font-size: 20px; - height: 25px; -} - -div.modal-container div.camera-top-bar { - display: none; -} - -span.camera-name { - float: left; - line-height: 25px; -} - -div.camera-buttons { - float: right; -} - -div.camera-button { - display: inline-block; - width: 24px; - height: 24px; - background-image: url(../img/top-bar-buttons.svg); - background-size: cover; - margin-left: 3px; - cursor: pointer; - transition: all 0.1s linear; -} - -div.camera-button.close { - background-position: 0px 0px; -} - -div.camera-button.full-screen { - background-position: -200% 0px; -} - -div.camera-button.configure { - background-position: -100% 0px; -} - -div.camera-button.media-pictures { - background-position: -300% 0px; -} - -div.camera-button.media-movies { - background-position: -400% 0px; -} - -div.camera-container { - position: relative; - padding: 0px; -} - -img.camera { - position: relative; - width: 100%; - display: block; - transition: opacity 0.2s linear; - opacity: 1; - min-height: 160px; -} - -img.camera.error, -img.camera.loading { - opacity: 0; -} - -div.camera-placeholder { - position: absolute; - top: 0px; - right: 0px; - bottom: 0px; - left: 0px; - text-align: center; - transition: opacity 0.2s linear; -} - -img.no-camera { - margin-top: 20%; - width: 30%; - opacity: 0.8; -} - -div.camera-progress { - background: rgba(0, 0, 0, 0.001); /* otherwise IE would not extend this as expected */ - position: absolute; - top: 0px; - right: 0px; - bottom: 0px; - left: 0px; - opacity: 0; - transition: all 0.2s linear; - text-align: center; - cursor: pointer; -} - -div.camera-progress.visible { - opacity: 0.4; -} - -img.camera-progress { - border: 10px solid white; - border-radius: 10px; - position: absolute; - top: 0px; - left: 0px; - bottom: 0px; - right: 0px; - margin: auto; -} - - - /* media queries */ - -@media all and (max-width: 1440px) { - /* smaller screens */ - - body { - font-size: 17px; - } -} - -@media all and (max-width: 1000px) { - /* small screens (mobile devices) */ - - div.page-container.stretched { - margin-left: 0px; - } - - div.settings.open { - box-shadow: 0px 0px 10px rgba(0,0,0,0.5); - background-color: rgba(40, 40, 40, 0.9); - } - - div.hostname { - display: none; - } - - div.media-list-entry-name { - font-size: 1em; - padding: 0.2em 0em 0em 0em; - } - - div.media-list-entry-details { - padding-top: 0.2em; - font-size: 1em; - text-align: center; - white-space: normal; - } - - div.media-list-entry-details span.details-moment { - display: none; - } - - div.media-list-entry-details span.details-moment-short { - display: block; - } -} - -@media all and (max-width: 400px) { - /* very small screens */ - - body { - font-size: 13px; - } - - div.camera-button { - background-size: cover; - width: 24px; - height: 24px; - } -} - -@media all and (max-width: 1900px) { - div.camera-frame, - div.camera-frame-place-holder { - width: 48%; - } -} - -@media all and (max-width: 1200px) { - div.page-container { - padding-left: 1%; - } - - div.camera-frame, - div.camera-frame-place-holder { - width: 98%; - } - - div.camera-frame:only-child, - div.camera-frame-place-holder:only-child { - width: 98%; - } -} diff --git a/static/css/ui.css b/static/css/ui.css deleted file mode 100644 index f114a8b..0000000 --- a/static/css/ui.css +++ /dev/null @@ -1,469 +0,0 @@ - - /* general */ - -::selection, -::-moz-selection, -::-webkit-selection { - background: #3498db; -} - -option::selection, -option::-moz-selection, -option::-webkit-selection { - background: transparent; -} - -input[type=checkbox].styled { - display: none; -} - -a { - color: #3498db; - text-decoration: inherit; - transition: color 0.1s ease; - cursor: pointer; -} - -a:HOVER { -} - -a:ACTIVE { - color: #317CAD; -} - - - /* buttons */ - -div.button { - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - cursor: pointer; - display: inline-block; -} - -div.button.dialog { - background-color: #414141; - min-width: 60px; - height: 1.2em; - line-height: 1.2em; - text-align: center; - padding: 0.2em 0.4em; - border: 1px solid #317CAD; - border-radius: 2px; - color: white; - transition: all 0.1s ease; -} - -div.button.dialog.default { - background-color: #317CAD; -} - -div.button.mouse-effect { - opacity: 0.7; - transition: opacity 0.1s ease; -} - -div.button.mouse-effect:HOVER { - opacity: 1; -} - -div.button.mouse-effect:ACTIVE { - opacity: 0.7; -} - - - /* check box */ - -div.check-box { - display: inline-block; - position: relative; - width: 2.5em; - height: 1em; - border: 1px solid #317CAD; - border-radius: 2px; - color: #aaaaaa; - vertical-align: middle; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - margin: 2px; - transition: all 0.2s ease; -} - -div.check-box:FOCUS, -div.check-box:HOVER { - border-color: #3498db; -} - -div.check-box-button { - width: 50%; - height: 100%; - left: 0px; - background-color: #414141; - color: #aaaaaa; - position: absolute; - text-align: center; - line-height: 1em; - transition: all 0.1s ease; -} - -span.check-box-text { - font-size: 0.5em; - font-weight: bold; - vertical-align: top; -} - -div.check-box.on div.check-box-button { - left: 50%; - background-color: #317CAD; - color: white; -} - -div.check-box.on:FOCUS div.check-box-button, -div.check-box.on:HOVER div.check-box-button { - background-color: #3498db; -} - - - /* input boxes */ - -input[type=password].styled, -input[type=text].styled, -textarea.styled { - width: 90%; - border: 1px solid #317CAD; - border-radius: 2px; - background-color: transparent; - padding: 1px; - color: #dddddd; - font-family: inherit; - font-size: 0.8em; - margin: 2px; - transition: all 0.1s ease; - resize: none; - vertical-align: middle; - white-space: nowrap; -} - -input[type=password].styled:FOCUS, -input[type=text].styled:FOCUS { - background-color: #414141; -} - -input[type=password].styled:HOVER, -input[type=password].styled:FOCUS, -input[type=text].styled:HOVER, -input[type=text].styled:FOCUS { - border-color: #3498db; - color: white; -} - -input[type=text].number { - width: 5em; -} - -input[type=text].error, -input[type=password].error, -input[type=file].error, -select.error { - background-image: url(../img/validation-error.svg) !important; - background-position: center right; - background-repeat: no-repeat; -} - -input[type=text].time { - width: 3.5em; -} - -input[readonly] { - border: 1px solid #555 !important; -} - - - /* combo box */ - -select.styled { - -webkit-appearance: none; - appearance: none; - width: 90%; - border: 1px solid #317CAD; - border-radius: 2px; - background-color: transparent; - padding: 1px 1.25em 1px 1px; - color: #dddddd; - font-family: inherit; - font-size: 0.8em; - margin: 2px; - background-image: url(../img/combo-box-arrow.svg); - background-repeat: no-repeat; - background-position: right center; - cursor: pointer; - vertical-align: middle; -} - -select.styled:FOCUS { - background-color: #414141; - appearance: auto; -} - -select.styled:HOVER, -select.styled:FOCUS { - border-color: #3498db; - color: white; -} - -.ff select.styled { - background-image: none; - padding-right: 1px; -} - - - /* slider */ - -input[type=text].range { - display: none; -} - -div.slider { - width: 82%; - height: 1.7em; - position: relative; - padding: 0.2em 0px; - margin-left: 5%; -} - -div.slider-labels { - position: relative; - width: 100%; - height: 0.5em; -} - -span.slider-label { - display: inline-block; - width: 20%; - text-align: center; - overflow: visible; - position: absolute; - font-size: 0.5em; -} - -div.slider-bar { - position: relative; - top: 7px; - left: -5px; - width: 100%; -} - -div.slider-bar-inside { - border: 1px solid #317CAD; - height: 3px; - position: relative; - top: 3px; - left: 7px; - transition: all 0.1s ease; -} - -div.slider:HOVER div.slider-bar-inside, -div.slider:FOCUS div.slider-bar-inside { - border-color: #3498db; -} - -div.slider:FOCUS div.slider-bar-inside { - background-color: #414141; -} - -div.slider-cursor { - background-image: url(../img/slider-arrow.svg); - width: 11px; - height: 18px; - position: absolute; - left: 0px; - top: -5px; - cursor: pointer; -} - -div.slider-cursor-label { - font-size: 0.6em; - margin-left: 1.5em; - margin-top: 0.15em; - background: rgba(30, 30, 30, 0.8); - vertical-align: top; - padding: 1px 2px; - border-radius: 3px; - display: none; -} - - - /* progress bar */ - -div.progress-bar-container { - position: relative; - height: 1em; - border: 1px solid #555; - vertical-align: middle; - margin: 0px 0.2em; - text-align: center; - line-height: 1em; -} - -div.progress-bar-fill { - position: absolute; - left: 0px; - top: 0px; - bottom: 0px; - width: 0%; - background-color: #555; - transition: width 0.1s ease; -} - -span.progress-bar-text { - vertical-align: middle; - font-size: 0.8em; - position: relative; -} - - - /* modal dialogs */ - -div.modal-glass { - display: none; - position: fixed; - z-index: 10000; - top: 0px; - right: 0px; - bottom: 0px; - left: 0px; - background-color: black; - opacity: 0; -} - -div.modal-container { - position: fixed; - display: none; - z-index: 10001; - background-color: #313131; - border-radius: 3px; - opacity: 0; - padding: 5px; - box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.3); -} - -div.modal-title-bar { - min-height: 1.5em; - line-height: 1.5em; - text-align: center; - position: relative; -} - -span.modal-title { - color: white; - font-size: 1.2em; - line-height: 1.2em; -} - -div.modal-close-button { - position: absolute; - top: 0.2em; - right: 0.3em; - width: 1.1em; - height: 1.1em; - background-image: url(../img/top-bar-buttons.svg); - background-size: cover; - cursor: pointer; -} - -table.modal-buttons-container { - width: 100%; - margin: auto; - text-align: center; - table-layout: fixed; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; -} - -table.modal-buttons-container td:not(:FIRST-CHILD) { - padding-left: 5px; -} - -table.modal-buttons-container div.button.dialog { - display: block; -} - -div.modal-progress { - border-radius: 10px; - background-image: url(../img/modal-progress.gif); - width: 64px; - height: 64px; - margin: auto; -} - -td.dialog-item-label { - text-align: right; - padding-right: 5px; -} - -td.dialog-item-value { - text-align: left; - padding-left: 5px; -} - -span.dialog-item-label { - font-size: 0.9em; -} - -div.dialog-item-separator { - height: 1px; - border-top: 1px solid #414141; - margin: 0.5em 1em; -} - - - /* popup message */ - -div.popup-message-container { - position: fixed; - display: none; - z-index: 10002; - background-color: #313131; - border-radius: 3px; - opacity: 0; - padding: 5px; - box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.3); - top: 60px; -} - -span.popup-message { - -} - -span.popup-message.info { - color: white; -} - -span.popup-message.error { - color: #FF6D55; -} - - - /* media queries */ - -@media all and (max-width: 400px) { - span.modal-title { - font-size: 1.5em; - - } - - div.modal-title-bar { - min-height: 2em; - line-height: 2em; - } - - div.modal-close-button { - background-size: cover; - width: 24px; - height: 24px; - } -} diff --git a/static/favicon.ico b/static/favicon.ico deleted file mode 100644 index 6cb844d..0000000 Binary files a/static/favicon.ico and /dev/null differ diff --git a/static/fnt/mavenpro-black-webfont.eot b/static/fnt/mavenpro-black-webfont.eot deleted file mode 100644 index 7ee4811..0000000 Binary files a/static/fnt/mavenpro-black-webfont.eot and /dev/null differ diff --git a/static/fnt/mavenpro-black-webfont.svg b/static/fnt/mavenpro-black-webfont.svg deleted file mode 100644 index b888155..0000000 --- a/static/fnt/mavenpro-black-webfont.svg +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/static/fnt/mavenpro-black-webfont.ttf b/static/fnt/mavenpro-black-webfont.ttf deleted file mode 100644 index 38d7d3f..0000000 Binary files a/static/fnt/mavenpro-black-webfont.ttf and /dev/null differ diff --git a/static/fnt/mavenpro-black-webfont.woff b/static/fnt/mavenpro-black-webfont.woff deleted file mode 100644 index 6ddfe8c..0000000 Binary files a/static/fnt/mavenpro-black-webfont.woff and /dev/null differ diff --git a/static/fnt/mavenpro-bold-webfont.eot b/static/fnt/mavenpro-bold-webfont.eot deleted file mode 100644 index 030d7cb..0000000 Binary files a/static/fnt/mavenpro-bold-webfont.eot and /dev/null differ diff --git a/static/fnt/mavenpro-bold-webfont.svg b/static/fnt/mavenpro-bold-webfont.svg deleted file mode 100644 index 43eb15b..0000000 --- a/static/fnt/mavenpro-bold-webfont.svg +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/static/fnt/mavenpro-bold-webfont.ttf b/static/fnt/mavenpro-bold-webfont.ttf deleted file mode 100644 index 2120446..0000000 Binary files a/static/fnt/mavenpro-bold-webfont.ttf and /dev/null differ diff --git a/static/fnt/mavenpro-bold-webfont.woff b/static/fnt/mavenpro-bold-webfont.woff deleted file mode 100644 index 7f73d1e..0000000 Binary files a/static/fnt/mavenpro-bold-webfont.woff and /dev/null differ diff --git a/static/fnt/mavenpro-medium-webfont.eot b/static/fnt/mavenpro-medium-webfont.eot deleted file mode 100644 index efb30f5..0000000 Binary files a/static/fnt/mavenpro-medium-webfont.eot and /dev/null differ diff --git a/static/fnt/mavenpro-medium-webfont.svg b/static/fnt/mavenpro-medium-webfont.svg deleted file mode 100644 index 8e8503b..0000000 --- a/static/fnt/mavenpro-medium-webfont.svg +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/static/fnt/mavenpro-medium-webfont.ttf b/static/fnt/mavenpro-medium-webfont.ttf deleted file mode 100644 index a73e903..0000000 Binary files a/static/fnt/mavenpro-medium-webfont.ttf and /dev/null differ diff --git a/static/fnt/mavenpro-medium-webfont.woff b/static/fnt/mavenpro-medium-webfont.woff deleted file mode 100644 index 9a0ed4d..0000000 Binary files a/static/fnt/mavenpro-medium-webfont.woff and /dev/null differ diff --git a/static/fnt/mavenpro-regular-webfont.eot b/static/fnt/mavenpro-regular-webfont.eot deleted file mode 100644 index b41ecdd..0000000 Binary files a/static/fnt/mavenpro-regular-webfont.eot and /dev/null differ diff --git a/static/fnt/mavenpro-regular-webfont.svg b/static/fnt/mavenpro-regular-webfont.svg deleted file mode 100644 index 074ad9f..0000000 --- a/static/fnt/mavenpro-regular-webfont.svg +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/static/fnt/mavenpro-regular-webfont.ttf b/static/fnt/mavenpro-regular-webfont.ttf deleted file mode 100644 index 8fb8aac..0000000 Binary files a/static/fnt/mavenpro-regular-webfont.ttf and /dev/null differ diff --git a/static/fnt/mavenpro-regular-webfont.woff b/static/fnt/mavenpro-regular-webfont.woff deleted file mode 100644 index 16f0e00..0000000 Binary files a/static/fnt/mavenpro-regular-webfont.woff and /dev/null differ diff --git a/static/img/apply-progress.gif b/static/img/apply-progress.gif deleted file mode 100644 index 610f733..0000000 Binary files a/static/img/apply-progress.gif and /dev/null differ diff --git a/static/img/arrows.svg b/static/img/arrows.svg deleted file mode 100644 index f1239a7..0000000 --- a/static/img/arrows.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/static/img/camera-progress.gif b/static/img/camera-progress.gif deleted file mode 100644 index 748c0c7..0000000 Binary files a/static/img/camera-progress.gif and /dev/null differ diff --git a/static/img/combo-box-arrow.svg b/static/img/combo-box-arrow.svg deleted file mode 100644 index 5f57b93..0000000 --- a/static/img/combo-box-arrow.svg +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/static/img/logout.svg b/static/img/logout.svg deleted file mode 100644 index cda383d..0000000 --- a/static/img/logout.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - -image/svg+xml \ No newline at end of file diff --git a/static/img/main-loading-progress.gif b/static/img/main-loading-progress.gif deleted file mode 100644 index 4f7a78e..0000000 Binary files a/static/img/main-loading-progress.gif and /dev/null differ diff --git a/static/img/modal-progress.gif b/static/img/modal-progress.gif deleted file mode 100644 index df1657c..0000000 Binary files a/static/img/modal-progress.gif and /dev/null differ diff --git a/static/img/motioneye-icon.svg b/static/img/motioneye-icon.svg deleted file mode 100644 index 93dfd9c..0000000 --- a/static/img/motioneye-icon.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - -image/svg+xml \ No newline at end of file diff --git a/static/img/motioneye-logo.svg b/static/img/motioneye-logo.svg deleted file mode 100644 index ea469a3..0000000 --- a/static/img/motioneye-logo.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -image/svg+xml diff --git a/static/img/no-camera.svg b/static/img/no-camera.svg deleted file mode 100644 index b7f1800..0000000 --- a/static/img/no-camera.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - -image/svg+xml \ No newline at end of file diff --git a/static/img/no-preview.svg b/static/img/no-preview.svg deleted file mode 100644 index 17c39f3..0000000 --- a/static/img/no-preview.svg +++ /dev/null @@ -1,74 +0,0 @@ - - - -image/svg+xml \ No newline at end of file diff --git a/static/img/settings.svg b/static/img/settings.svg deleted file mode 100644 index c11c28e..0000000 --- a/static/img/settings.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - diff --git a/static/img/slider-arrow.svg b/static/img/slider-arrow.svg deleted file mode 100644 index 543e0e9..0000000 --- a/static/img/slider-arrow.svg +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/static/img/small-progress.gif b/static/img/small-progress.gif deleted file mode 100644 index e0be29f..0000000 Binary files a/static/img/small-progress.gif and /dev/null differ diff --git a/static/img/top-bar-buttons.svg b/static/img/top-bar-buttons.svg deleted file mode 100644 index a10c006..0000000 --- a/static/img/top-bar-buttons.svg +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - diff --git a/static/img/validation-error.svg b/static/img/validation-error.svg deleted file mode 100644 index 6c4d5da..0000000 --- a/static/img/validation-error.svg +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/static/js/css-browser-selector.js b/static/js/css-browser-selector.js deleted file mode 100644 index 79759df..0000000 --- a/static/js/css-browser-selector.js +++ /dev/null @@ -1,14 +0,0 @@ -/* -CSS Browser Selector js v0.5.3 (July 2, 2013) - --- original -- -Rafael Lima (http://rafael.adm.br) -http://rafael.adm.br/css_browser_selector -License: http://creativecommons.org/licenses/by/2.5/ -Contributors: http://rafael.adm.br/css_browser_selector#contributors --- /original -- - -Fork project: http://code.google.com/p/css-browser-selector/ -Song Hyo-Jin (shj at xenosi.de) -*/ -function css_browser_selector(n){var b=n.toLowerCase(),f=function(c){return b.indexOf(c)>-1},h="gecko",k="webkit",p="safari",j="chrome",d="opera",e="mobile",l=0,a=window.devicePixelRatio?(window.devicePixelRatio+"").replace(".","_"):"1";var i=[(!(/opera|webtv/.test(b))&&/msie\s(\d+)/.test(b)&&(l=RegExp.$1*1))?("ie ie"+l+((l==6||l==7)?" ie67 ie678 ie6789":(l==8)?" ie678 ie6789":(l==9)?" ie6789 ie9m":(l>9)?" ie9m":"")):(/firefox\/(\d+)\.(\d+)/.test(b)&&(re=RegExp))?h+" ff ff"+re.$1+" ff"+re.$1+"_"+re.$2:f("gecko/")?h:f(d)?d+(/version\/(\d+)/.test(b)?" "+d+RegExp.$1:(/opera(\s|\/)(\d+)/.test(b)?" "+d+RegExp.$2:"")):f("konqueror")?"konqueror":f("blackberry")?e+" blackberry":(f(j)||f("crios"))?k+" "+j:f("iron")?k+" iron":!f("cpu os")&&f("applewebkit/")?k+" "+p:f("mozilla/")?h:"",f("android")?e+" android":"",f("tablet")?"tablet":"",f("j2me")?e+" j2me":f("ipad; u; cpu os")?e+" chrome android tablet":f("ipad;u;cpu os")?e+" chromedef android tablet":f("iphone")?e+" ios iphone":f("ipod")?e+" ios ipod":f("ipad")?e+" ios ipad tablet":f("mac")?"mac":f("darwin")?"mac":f("webtv")?"webtv":f("win")?"win"+(f("windows nt 6.0")?" vista":""):f("freebsd")?"freebsd":(f("x11")||f("linux"))?"linux":"",(a!="1")?" retina ratio"+a:"","js portrait"].join(" ");if(window.jQuery&&!window.jQuery.browser){window.jQuery.browser=l?{msie:1,version:l}:{}}return i}(function(j,b){var c=css_browser_selector(navigator.userAgent);var g=j.documentElement;g.className+=" "+c;var a=c.replace(/^\s*|\s*$/g,"").split(/ +/);b.CSSBS=1;for(var f=0;fp){v.removeClass(h).addClass(k)}else{v.removeClass(k).addClass(h)}if(l==o){return}o=l;clearTimeout(s)}catch(m){}s=setTimeout(n,100)}function n(){try{v.removeClass(w);v.addClass((o<=360)?i:(o<=640)?u:(o<=768)?x:(o<=1024)?r:"pc")}catch(l){}}q(b).on("resize orientationchange",d).trigger("resize")})(b.jQuery)}})(document,window); diff --git a/static/js/frame.js b/static/js/frame.js deleted file mode 100644 index 276e100..0000000 --- a/static/js/frame.js +++ /dev/null @@ -1,150 +0,0 @@ - -var refreshDisabled = false; - - - /* camera frame */ - -function setupCameraFrame() { - var cameraFrameDiv = $('div.camera-frame') - var cameraPlaceholder = cameraFrameDiv.find('div.camera-placeholder'); - var cameraProgress = cameraFrameDiv.find('div.camera-progress'); - var cameraImg = cameraFrameDiv.find('img.camera'); - var cameraId = cameraFrameDiv.attr('id').substring(6); - var progressImg = cameraFrameDiv.find('img.camera-progress'); - var body = $('body'); - - cameraFrameDiv[0].refreshDivider = 0; - cameraFrameDiv[0].streamingFramerate = parseInt(cameraFrameDiv.attr('streaming_framerate')) || 1; - cameraFrameDiv[0].streamingServerResize = cameraFrameDiv.attr('streaming_server_resize') == 'True'; - cameraFrameDiv[0].proto = cameraFrameDiv.attr('proto'); - cameraFrameDiv[0].url = cameraFrameDiv.attr('url'); - progressImg.attr('src', staticUrl + 'img/camera-progress.gif'); - - cameraProgress.addClass('visible'); - cameraPlaceholder.css('opacity', '0'); - - /* fade in */ - cameraFrameDiv.animate({'opacity': 1}, 100); - - /* error and load handlers */ - cameraImg.error(function () { - this.error = true; - this.loading = 0; - - cameraImg.addClass('error').removeClass('loading'); - cameraPlaceholder.css('opacity', 1); - cameraProgress.removeClass('visible'); - cameraFrameDiv.removeClass('motion-detected'); - }); - cameraImg.load(function () { - if (refreshDisabled) { - return; /* refresh temporarily disabled for updating */ - } - - this.error = false; - this.loading = 0; - - cameraImg.removeClass('error').removeClass('loading'); - cameraPlaceholder.css('opacity', 0); - cameraProgress.removeClass('visible'); - - /* there's no point in looking for a cookie update more often than once every second */ - var now = new Date().getTime(); - if ((!this.lastCookieTime || now - this.lastCookieTime > 1000) && (cameraFrameDiv[0].proto != 'mjpeg')) { - if (getCookie('motion_detected_' + cameraId) == 'true') { - cameraFrameDiv.addClass('motion-detected'); - } - else { - cameraFrameDiv.removeClass('motion-detected'); - } - - this.lastCookieTime = now; - } - - if (this.naturalWidth / this.naturalHeight > body.width() / body.height()) { - cameraImg.css('width', '100%'); - cameraImg.css('height', 'auto'); - } - else { - cameraImg.css('width', 'auto'); - cameraImg.css('height', '100%'); - } - }); - - cameraImg.addClass('loading'); -} - -function refreshCameraFrame() { - var $cameraFrame = $('div.camera-frame'); - var cameraFrame = $cameraFrame[0]; - var img = $cameraFrame.find('img.camera')[0]; - var cameraId = cameraFrame.id.substring(6); - - if (cameraFrame.proto == 'mjpeg') { - /* no manual refresh for simple mjpeg cameras */ - var url = cameraFrame.url.replace('127.0.0.1', window.location.host.split(':')[0]); - url += (url.indexOf('?') > 0 ? '&' : '?') + '_=' + new Date().getTime(); - img.src = url; - return; - } - - /* at a refresh interval of 50ms, the refresh rate is limited to 20 fps */ - var count = 1000 / (refreshInterval * cameraFrame.streamingFramerate); - if (count <= 2) { - /* skipping frames (showing the same frame twice) at this rate won't be visible, - * while the effective framerate will be as close as possible to the motion's one */ - count -= 1; - } - - if (img.error) { - /* in case of error, decrease the refresh rate to 1 fps */ - count = 1000 / refreshInterval; - } - - if (cameraFrame.refreshDivider < count) { - cameraFrame.refreshDivider++; - } - else { - (function () { - if (refreshDisabled) { - /* camera refreshing disabled, retry later */ - - return; - } - - if (img.loading) { - img.loading++; /* increases each time the camera would refresh but is still loading */ - - if (img.loading > 2 * 1000 / refreshInterval) { /* limits the retry at one every two seconds */ - img.loading = 0; - } - else { - return; /* wait for the previous frame to finish loading */ - } - } - - var timestamp = new Date().getTime(); - var uri = baseUri + 'picture/' + cameraId + '/current/?_=' + timestamp; - if (cameraFrame.serverSideResize) { - uri += '&width=' + img.width; - } - - uri = addAuthParams('GET', uri); - img.src = uri; - img.loading = 1; - - cameraFrame.refreshDivider = 0; - })(); - } - - setTimeout(refreshCameraFrame, refreshInterval); -} - - - /* startup function */ - -$(document).ready(function () { - setupCameraFrame(); - refreshCameraFrame(); -}); - diff --git a/static/js/jquery.min.js b/static/js/jquery.min.js deleted file mode 100644 index ce1b6b6..0000000 --- a/static/js/jquery.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/*! jQuery v1.10.2 | (c) 2005, 2013 jQuery Foundation, Inc. | jquery.org/license -*/ -(function(e,t){var n,r,i=typeof t,o=e.location,a=e.document,s=a.documentElement,l=e.jQuery,u=e.$,c={},p=[],f="1.10.2",d=p.concat,h=p.push,g=p.slice,m=p.indexOf,y=c.toString,v=c.hasOwnProperty,b=f.trim,x=function(e,t){return new x.fn.init(e,t,r)},w=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,T=/\S+/g,C=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,N=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,k=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,E=/^[\],:{}\s]*$/,S=/(?:^|:|,)(?:\s*\[)+/g,A=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,j=/"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g,D=/^-ms-/,L=/-([\da-z])/gi,H=function(e,t){return t.toUpperCase()},q=function(e){(a.addEventListener||"load"===e.type||"complete"===a.readyState)&&(_(),x.ready())},_=function(){a.addEventListener?(a.removeEventListener("DOMContentLoaded",q,!1),e.removeEventListener("load",q,!1)):(a.detachEvent("onreadystatechange",q),e.detachEvent("onload",q))};x.fn=x.prototype={jquery:f,constructor:x,init:function(e,n,r){var i,o;if(!e)return this;if("string"==typeof e){if(i="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:N.exec(e),!i||!i[1]&&n)return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e);if(i[1]){if(n=n instanceof x?n[0]:n,x.merge(this,x.parseHTML(i[1],n&&n.nodeType?n.ownerDocument||n:a,!0)),k.test(i[1])&&x.isPlainObject(n))for(i in n)x.isFunction(this[i])?this[i](n[i]):this.attr(i,n[i]);return this}if(o=a.getElementById(i[2]),o&&o.parentNode){if(o.id!==i[2])return r.find(e);this.length=1,this[0]=o}return this.context=a,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):x.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),x.makeArray(e,this))},selector:"",length:0,toArray:function(){return g.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=x.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return x.each(this,e,t)},ready:function(e){return x.ready.promise().done(e),this},slice:function(){return this.pushStack(g.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(x.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:h,sort:[].sort,splice:[].splice},x.fn.init.prototype=x.fn,x.extend=x.fn.extend=function(){var e,n,r,i,o,a,s=arguments[0]||{},l=1,u=arguments.length,c=!1;for("boolean"==typeof s&&(c=s,s=arguments[1]||{},l=2),"object"==typeof s||x.isFunction(s)||(s={}),u===l&&(s=this,--l);u>l;l++)if(null!=(o=arguments[l]))for(i in o)e=s[i],r=o[i],s!==r&&(c&&r&&(x.isPlainObject(r)||(n=x.isArray(r)))?(n?(n=!1,a=e&&x.isArray(e)?e:[]):a=e&&x.isPlainObject(e)?e:{},s[i]=x.extend(c,a,r)):r!==t&&(s[i]=r));return s},x.extend({expando:"jQuery"+(f+Math.random()).replace(/\D/g,""),noConflict:function(t){return e.$===x&&(e.$=u),t&&e.jQuery===x&&(e.jQuery=l),x},isReady:!1,readyWait:1,holdReady:function(e){e?x.readyWait++:x.ready(!0)},ready:function(e){if(e===!0?!--x.readyWait:!x.isReady){if(!a.body)return setTimeout(x.ready);x.isReady=!0,e!==!0&&--x.readyWait>0||(n.resolveWith(a,[x]),x.fn.trigger&&x(a).trigger("ready").off("ready"))}},isFunction:function(e){return"function"===x.type(e)},isArray:Array.isArray||function(e){return"array"===x.type(e)},isWindow:function(e){return null!=e&&e==e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?c[y.call(e)]||"object":typeof e},isPlainObject:function(e){var n;if(!e||"object"!==x.type(e)||e.nodeType||x.isWindow(e))return!1;try{if(e.constructor&&!v.call(e,"constructor")&&!v.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(r){return!1}if(x.support.ownLast)for(n in e)return v.call(e,n);for(n in e);return n===t||v.call(e,n)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||a;var r=k.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=x.buildFragment([e],t,i),i&&x(i).remove(),x.merge([],r.childNodes))},parseJSON:function(n){return e.JSON&&e.JSON.parse?e.JSON.parse(n):null===n?n:"string"==typeof n&&(n=x.trim(n),n&&E.test(n.replace(A,"@").replace(j,"]").replace(S,"")))?Function("return "+n)():(x.error("Invalid JSON: "+n),t)},parseXML:function(n){var r,i;if(!n||"string"!=typeof n)return null;try{e.DOMParser?(i=new DOMParser,r=i.parseFromString(n,"text/xml")):(r=new ActiveXObject("Microsoft.XMLDOM"),r.async="false",r.loadXML(n))}catch(o){r=t}return r&&r.documentElement&&!r.getElementsByTagName("parsererror").length||x.error("Invalid XML: "+n),r},noop:function(){},globalEval:function(t){t&&x.trim(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(D,"ms-").replace(L,H)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,n){var r,i=0,o=e.length,a=M(e);if(n){if(a){for(;o>i;i++)if(r=t.apply(e[i],n),r===!1)break}else for(i in e)if(r=t.apply(e[i],n),r===!1)break}else if(a){for(;o>i;i++)if(r=t.call(e[i],i,e[i]),r===!1)break}else for(i in e)if(r=t.call(e[i],i,e[i]),r===!1)break;return e},trim:b&&!b.call("\ufeff\u00a0")?function(e){return null==e?"":b.call(e)}:function(e){return null==e?"":(e+"").replace(C,"")},makeArray:function(e,t){var n=t||[];return null!=e&&(M(Object(e))?x.merge(n,"string"==typeof e?[e]:e):h.call(n,e)),n},inArray:function(e,t,n){var r;if(t){if(m)return m.call(t,e,n);for(r=t.length,n=n?0>n?Math.max(0,r+n):n:0;r>n;n++)if(n in t&&t[n]===e)return n}return-1},merge:function(e,n){var r=n.length,i=e.length,o=0;if("number"==typeof r)for(;r>o;o++)e[i++]=n[o];else while(n[o]!==t)e[i++]=n[o++];return e.length=i,e},grep:function(e,t,n){var r,i=[],o=0,a=e.length;for(n=!!n;a>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,n){var r,i=0,o=e.length,a=M(e),s=[];if(a)for(;o>i;i++)r=t(e[i],i,n),null!=r&&(s[s.length]=r);else for(i in e)r=t(e[i],i,n),null!=r&&(s[s.length]=r);return d.apply([],s)},guid:1,proxy:function(e,n){var r,i,o;return"string"==typeof n&&(o=e[n],n=e,e=o),x.isFunction(e)?(r=g.call(arguments,2),i=function(){return e.apply(n||this,r.concat(g.call(arguments)))},i.guid=e.guid=e.guid||x.guid++,i):t},access:function(e,n,r,i,o,a,s){var l=0,u=e.length,c=null==r;if("object"===x.type(r)){o=!0;for(l in r)x.access(e,n,l,r[l],!0,a,s)}else if(i!==t&&(o=!0,x.isFunction(i)||(s=!0),c&&(s?(n.call(e,i),n=null):(c=n,n=function(e,t,n){return c.call(x(e),n)})),n))for(;u>l;l++)n(e[l],r,s?i:i.call(e[l],l,n(e[l],r)));return o?e:c?n.call(e):u?n(e[0],r):a},now:function(){return(new Date).getTime()},swap:function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=a[o];return i}}),x.ready.promise=function(t){if(!n)if(n=x.Deferred(),"complete"===a.readyState)setTimeout(x.ready);else if(a.addEventListener)a.addEventListener("DOMContentLoaded",q,!1),e.addEventListener("load",q,!1);else{a.attachEvent("onreadystatechange",q),e.attachEvent("onload",q);var r=!1;try{r=null==e.frameElement&&a.documentElement}catch(i){}r&&r.doScroll&&function o(){if(!x.isReady){try{r.doScroll("left")}catch(e){return setTimeout(o,50)}_(),x.ready()}}()}return n.promise(t)},x.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){c["[object "+t+"]"]=t.toLowerCase()});function M(e){var t=e.length,n=x.type(e);return x.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}r=x(a),function(e,t){var n,r,i,o,a,s,l,u,c,p,f,d,h,g,m,y,v,b="sizzle"+-new Date,w=e.document,T=0,C=0,N=st(),k=st(),E=st(),S=!1,A=function(e,t){return e===t?(S=!0,0):0},j=typeof t,D=1<<31,L={}.hasOwnProperty,H=[],q=H.pop,_=H.push,M=H.push,O=H.slice,F=H.indexOf||function(e){var t=0,n=this.length;for(;n>t;t++)if(this[t]===e)return t;return-1},B="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",P="[\\x20\\t\\r\\n\\f]",R="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",W=R.replace("w","w#"),$="\\["+P+"*("+R+")"+P+"*(?:([*^$|!~]?=)"+P+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+W+")|)|)"+P+"*\\]",I=":("+R+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+$.replace(3,8)+")*)|.*)\\)|)",z=RegExp("^"+P+"+|((?:^|[^\\\\])(?:\\\\.)*)"+P+"+$","g"),X=RegExp("^"+P+"*,"+P+"*"),U=RegExp("^"+P+"*([>+~]|"+P+")"+P+"*"),V=RegExp(P+"*[+~]"),Y=RegExp("="+P+"*([^\\]'\"]*)"+P+"*\\]","g"),J=RegExp(I),G=RegExp("^"+W+"$"),Q={ID:RegExp("^#("+R+")"),CLASS:RegExp("^\\.("+R+")"),TAG:RegExp("^("+R.replace("w","w*")+")"),ATTR:RegExp("^"+$),PSEUDO:RegExp("^"+I),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+P+"*(even|odd|(([+-]|)(\\d*)n|)"+P+"*(?:([+-]|)"+P+"*(\\d+)|))"+P+"*\\)|)","i"),bool:RegExp("^(?:"+B+")$","i"),needsContext:RegExp("^"+P+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+P+"*((?:-\\d)?\\d*)"+P+"*\\)|)(?=[^-]|$)","i")},K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,et=/^(?:input|select|textarea|button)$/i,tt=/^h\d$/i,nt=/'|\\/g,rt=RegExp("\\\\([\\da-f]{1,6}"+P+"?|("+P+")|.)","ig"),it=function(e,t,n){var r="0x"+t-65536;return r!==r||n?t:0>r?String.fromCharCode(r+65536):String.fromCharCode(55296|r>>10,56320|1023&r)};try{M.apply(H=O.call(w.childNodes),w.childNodes),H[w.childNodes.length].nodeType}catch(ot){M={apply:H.length?function(e,t){_.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function at(e,t,n,i){var o,a,s,l,u,c,d,m,y,x;if((t?t.ownerDocument||t:w)!==f&&p(t),t=t||f,n=n||[],!e||"string"!=typeof e)return n;if(1!==(l=t.nodeType)&&9!==l)return[];if(h&&!i){if(o=Z.exec(e))if(s=o[1]){if(9===l){if(a=t.getElementById(s),!a||!a.parentNode)return n;if(a.id===s)return n.push(a),n}else if(t.ownerDocument&&(a=t.ownerDocument.getElementById(s))&&v(t,a)&&a.id===s)return n.push(a),n}else{if(o[2])return M.apply(n,t.getElementsByTagName(e)),n;if((s=o[3])&&r.getElementsByClassName&&t.getElementsByClassName)return M.apply(n,t.getElementsByClassName(s)),n}if(r.qsa&&(!g||!g.test(e))){if(m=d=b,y=t,x=9===l&&e,1===l&&"object"!==t.nodeName.toLowerCase()){c=mt(e),(d=t.getAttribute("id"))?m=d.replace(nt,"\\$&"):t.setAttribute("id",m),m="[id='"+m+"'] ",u=c.length;while(u--)c[u]=m+yt(c[u]);y=V.test(e)&&t.parentNode||t,x=c.join(",")}if(x)try{return M.apply(n,y.querySelectorAll(x)),n}catch(T){}finally{d||t.removeAttribute("id")}}}return kt(e.replace(z,"$1"),t,n,i)}function st(){var e=[];function t(n,r){return e.push(n+=" ")>o.cacheLength&&delete t[e.shift()],t[n]=r}return t}function lt(e){return e[b]=!0,e}function ut(e){var t=f.createElement("div");try{return!!e(t)}catch(n){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function ct(e,t){var n=e.split("|"),r=e.length;while(r--)o.attrHandle[n[r]]=t}function pt(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&(~t.sourceIndex||D)-(~e.sourceIndex||D);if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function ft(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function dt(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function ht(e){return lt(function(t){return t=+t,lt(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}s=at.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},r=at.support={},p=at.setDocument=function(e){var n=e?e.ownerDocument||e:w,i=n.defaultView;return n!==f&&9===n.nodeType&&n.documentElement?(f=n,d=n.documentElement,h=!s(n),i&&i.attachEvent&&i!==i.top&&i.attachEvent("onbeforeunload",function(){p()}),r.attributes=ut(function(e){return e.className="i",!e.getAttribute("className")}),r.getElementsByTagName=ut(function(e){return e.appendChild(n.createComment("")),!e.getElementsByTagName("*").length}),r.getElementsByClassName=ut(function(e){return e.innerHTML="
",e.firstChild.className="i",2===e.getElementsByClassName("i").length}),r.getById=ut(function(e){return d.appendChild(e).id=b,!n.getElementsByName||!n.getElementsByName(b).length}),r.getById?(o.find.ID=function(e,t){if(typeof t.getElementById!==j&&h){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},o.filter.ID=function(e){var t=e.replace(rt,it);return function(e){return e.getAttribute("id")===t}}):(delete o.find.ID,o.filter.ID=function(e){var t=e.replace(rt,it);return function(e){var n=typeof e.getAttributeNode!==j&&e.getAttributeNode("id");return n&&n.value===t}}),o.find.TAG=r.getElementsByTagName?function(e,n){return typeof n.getElementsByTagName!==j?n.getElementsByTagName(e):t}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},o.find.CLASS=r.getElementsByClassName&&function(e,n){return typeof n.getElementsByClassName!==j&&h?n.getElementsByClassName(e):t},m=[],g=[],(r.qsa=K.test(n.querySelectorAll))&&(ut(function(e){e.innerHTML="",e.querySelectorAll("[selected]").length||g.push("\\["+P+"*(?:value|"+B+")"),e.querySelectorAll(":checked").length||g.push(":checked")}),ut(function(e){var t=n.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("t",""),e.querySelectorAll("[t^='']").length&&g.push("[*^$]="+P+"*(?:''|\"\")"),e.querySelectorAll(":enabled").length||g.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),g.push(",.*:")})),(r.matchesSelector=K.test(y=d.webkitMatchesSelector||d.mozMatchesSelector||d.oMatchesSelector||d.msMatchesSelector))&&ut(function(e){r.disconnectedMatch=y.call(e,"div"),y.call(e,"[s!='']:x"),m.push("!=",I)}),g=g.length&&RegExp(g.join("|")),m=m.length&&RegExp(m.join("|")),v=K.test(d.contains)||d.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},A=d.compareDocumentPosition?function(e,t){if(e===t)return S=!0,0;var i=t.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(t);return i?1&i||!r.sortDetached&&t.compareDocumentPosition(e)===i?e===n||v(w,e)?-1:t===n||v(w,t)?1:c?F.call(c,e)-F.call(c,t):0:4&i?-1:1:e.compareDocumentPosition?-1:1}:function(e,t){var r,i=0,o=e.parentNode,a=t.parentNode,s=[e],l=[t];if(e===t)return S=!0,0;if(!o||!a)return e===n?-1:t===n?1:o?-1:a?1:c?F.call(c,e)-F.call(c,t):0;if(o===a)return pt(e,t);r=e;while(r=r.parentNode)s.unshift(r);r=t;while(r=r.parentNode)l.unshift(r);while(s[i]===l[i])i++;return i?pt(s[i],l[i]):s[i]===w?-1:l[i]===w?1:0},n):f},at.matches=function(e,t){return at(e,null,null,t)},at.matchesSelector=function(e,t){if((e.ownerDocument||e)!==f&&p(e),t=t.replace(Y,"='$1']"),!(!r.matchesSelector||!h||m&&m.test(t)||g&&g.test(t)))try{var n=y.call(e,t);if(n||r.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(i){}return at(t,f,null,[e]).length>0},at.contains=function(e,t){return(e.ownerDocument||e)!==f&&p(e),v(e,t)},at.attr=function(e,n){(e.ownerDocument||e)!==f&&p(e);var i=o.attrHandle[n.toLowerCase()],a=i&&L.call(o.attrHandle,n.toLowerCase())?i(e,n,!h):t;return a===t?r.attributes||!h?e.getAttribute(n):(a=e.getAttributeNode(n))&&a.specified?a.value:null:a},at.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},at.uniqueSort=function(e){var t,n=[],i=0,o=0;if(S=!r.detectDuplicates,c=!r.sortStable&&e.slice(0),e.sort(A),S){while(t=e[o++])t===e[o]&&(i=n.push(o));while(i--)e.splice(n[i],1)}return e},a=at.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=a(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=a(t);return n},o=at.selectors={cacheLength:50,createPseudo:lt,match:Q,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(rt,it),e[3]=(e[4]||e[5]||"").replace(rt,it),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||at.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&at.error(e[0]),e},PSEUDO:function(e){var n,r=!e[5]&&e[2];return Q.CHILD.test(e[0])?null:(e[3]&&e[4]!==t?e[2]=e[4]:r&&J.test(r)&&(n=mt(r,!0))&&(n=r.indexOf(")",r.length-n)-r.length)&&(e[0]=e[0].slice(0,n),e[2]=r.slice(0,n)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(rt,it).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=N[e+" "];return t||(t=RegExp("(^|"+P+")"+e+"("+P+"|$)"))&&N(e,function(e){return t.test("string"==typeof e.className&&e.className||typeof e.getAttribute!==j&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=at.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.slice(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,l){var u,c,p,f,d,h,g=o!==a?"nextSibling":"previousSibling",m=t.parentNode,y=s&&t.nodeName.toLowerCase(),v=!l&&!s;if(m){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===y:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&v){c=m[b]||(m[b]={}),u=c[e]||[],d=u[0]===T&&u[1],f=u[0]===T&&u[2],p=d&&m.childNodes[d];while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if(1===p.nodeType&&++f&&p===t){c[e]=[T,d,f];break}}else if(v&&(u=(t[b]||(t[b]={}))[e])&&u[0]===T)f=u[1];else while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===y:1===p.nodeType)&&++f&&(v&&((p[b]||(p[b]={}))[e]=[T,f]),p===t))break;return f-=i,f===r||0===f%r&&f/r>=0}}},PSEUDO:function(e,t){var n,r=o.pseudos[e]||o.setFilters[e.toLowerCase()]||at.error("unsupported pseudo: "+e);return r[b]?r(t):r.length>1?(n=[e,e,"",t],o.setFilters.hasOwnProperty(e.toLowerCase())?lt(function(e,n){var i,o=r(e,t),a=o.length;while(a--)i=F.call(e,o[a]),e[i]=!(n[i]=o[a])}):function(e){return r(e,0,n)}):r}},pseudos:{not:lt(function(e){var t=[],n=[],r=l(e.replace(z,"$1"));return r[b]?lt(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:lt(function(e){return function(t){return at(e,t).length>0}}),contains:lt(function(e){return function(t){return(t.textContent||t.innerText||a(t)).indexOf(e)>-1}}),lang:lt(function(e){return G.test(e||"")||at.error("unsupported lang: "+e),e=e.replace(rt,it).toLowerCase(),function(t){var n;do if(n=h?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===d},focus:function(e){return e===f.activeElement&&(!f.hasFocus||f.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!o.pseudos.empty(e)},header:function(e){return tt.test(e.nodeName)},input:function(e){return et.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:ht(function(){return[0]}),last:ht(function(e,t){return[t-1]}),eq:ht(function(e,t,n){return[0>n?n+t:n]}),even:ht(function(e,t){var n=0;for(;t>n;n+=2)e.push(n);return e}),odd:ht(function(e,t){var n=1;for(;t>n;n+=2)e.push(n);return e}),lt:ht(function(e,t,n){var r=0>n?n+t:n;for(;--r>=0;)e.push(r);return e}),gt:ht(function(e,t,n){var r=0>n?n+t:n;for(;t>++r;)e.push(r);return e})}},o.pseudos.nth=o.pseudos.eq;for(n in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})o.pseudos[n]=ft(n);for(n in{submit:!0,reset:!0})o.pseudos[n]=dt(n);function gt(){}gt.prototype=o.filters=o.pseudos,o.setFilters=new gt;function mt(e,t){var n,r,i,a,s,l,u,c=k[e+" "];if(c)return t?0:c.slice(0);s=e,l=[],u=o.preFilter;while(s){(!n||(r=X.exec(s)))&&(r&&(s=s.slice(r[0].length)||s),l.push(i=[])),n=!1,(r=U.exec(s))&&(n=r.shift(),i.push({value:n,type:r[0].replace(z," ")}),s=s.slice(n.length));for(a in o.filter)!(r=Q[a].exec(s))||u[a]&&!(r=u[a](r))||(n=r.shift(),i.push({value:n,type:a,matches:r}),s=s.slice(n.length));if(!n)break}return t?s.length:s?at.error(e):k(e,l).slice(0)}function yt(e){var t=0,n=e.length,r="";for(;n>t;t++)r+=e[t].value;return r}function vt(e,t,n){var r=t.dir,o=n&&"parentNode"===r,a=C++;return t.first?function(t,n,i){while(t=t[r])if(1===t.nodeType||o)return e(t,n,i)}:function(t,n,s){var l,u,c,p=T+" "+a;if(s){while(t=t[r])if((1===t.nodeType||o)&&e(t,n,s))return!0}else while(t=t[r])if(1===t.nodeType||o)if(c=t[b]||(t[b]={}),(u=c[r])&&u[0]===p){if((l=u[1])===!0||l===i)return l===!0}else if(u=c[r]=[p],u[1]=e(t,n,s)||i,u[1]===!0)return!0}}function bt(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function xt(e,t,n,r,i){var o,a=[],s=0,l=e.length,u=null!=t;for(;l>s;s++)(o=e[s])&&(!n||n(o,r,i))&&(a.push(o),u&&t.push(s));return a}function wt(e,t,n,r,i,o){return r&&!r[b]&&(r=wt(r)),i&&!i[b]&&(i=wt(i,o)),lt(function(o,a,s,l){var u,c,p,f=[],d=[],h=a.length,g=o||Nt(t||"*",s.nodeType?[s]:s,[]),m=!e||!o&&t?g:xt(g,f,e,s,l),y=n?i||(o?e:h||r)?[]:a:m;if(n&&n(m,y,s,l),r){u=xt(y,d),r(u,[],s,l),c=u.length;while(c--)(p=u[c])&&(y[d[c]]=!(m[d[c]]=p))}if(o){if(i||e){if(i){u=[],c=y.length;while(c--)(p=y[c])&&u.push(m[c]=p);i(null,y=[],u,l)}c=y.length;while(c--)(p=y[c])&&(u=i?F.call(o,p):f[c])>-1&&(o[u]=!(a[u]=p))}}else y=xt(y===a?y.splice(h,y.length):y),i?i(null,a,y,l):M.apply(a,y)})}function Tt(e){var t,n,r,i=e.length,a=o.relative[e[0].type],s=a||o.relative[" "],l=a?1:0,c=vt(function(e){return e===t},s,!0),p=vt(function(e){return F.call(t,e)>-1},s,!0),f=[function(e,n,r){return!a&&(r||n!==u)||((t=n).nodeType?c(e,n,r):p(e,n,r))}];for(;i>l;l++)if(n=o.relative[e[l].type])f=[vt(bt(f),n)];else{if(n=o.filter[e[l].type].apply(null,e[l].matches),n[b]){for(r=++l;i>r;r++)if(o.relative[e[r].type])break;return wt(l>1&&bt(f),l>1&&yt(e.slice(0,l-1).concat({value:" "===e[l-2].type?"*":""})).replace(z,"$1"),n,r>l&&Tt(e.slice(l,r)),i>r&&Tt(e=e.slice(r)),i>r&&yt(e))}f.push(n)}return bt(f)}function Ct(e,t){var n=0,r=t.length>0,a=e.length>0,s=function(s,l,c,p,d){var h,g,m,y=[],v=0,b="0",x=s&&[],w=null!=d,C=u,N=s||a&&o.find.TAG("*",d&&l.parentNode||l),k=T+=null==C?1:Math.random()||.1;for(w&&(u=l!==f&&l,i=n);null!=(h=N[b]);b++){if(a&&h){g=0;while(m=e[g++])if(m(h,l,c)){p.push(h);break}w&&(T=k,i=++n)}r&&((h=!m&&h)&&v--,s&&x.push(h))}if(v+=b,r&&b!==v){g=0;while(m=t[g++])m(x,y,l,c);if(s){if(v>0)while(b--)x[b]||y[b]||(y[b]=q.call(p));y=xt(y)}M.apply(p,y),w&&!s&&y.length>0&&v+t.length>1&&at.uniqueSort(p)}return w&&(T=k,u=C),x};return r?lt(s):s}l=at.compile=function(e,t){var n,r=[],i=[],o=E[e+" "];if(!o){t||(t=mt(e)),n=t.length;while(n--)o=Tt(t[n]),o[b]?r.push(o):i.push(o);o=E(e,Ct(i,r))}return o};function Nt(e,t,n){var r=0,i=t.length;for(;i>r;r++)at(e,t[r],n);return n}function kt(e,t,n,i){var a,s,u,c,p,f=mt(e);if(!i&&1===f.length){if(s=f[0]=f[0].slice(0),s.length>2&&"ID"===(u=s[0]).type&&r.getById&&9===t.nodeType&&h&&o.relative[s[1].type]){if(t=(o.find.ID(u.matches[0].replace(rt,it),t)||[])[0],!t)return n;e=e.slice(s.shift().value.length)}a=Q.needsContext.test(e)?0:s.length;while(a--){if(u=s[a],o.relative[c=u.type])break;if((p=o.find[c])&&(i=p(u.matches[0].replace(rt,it),V.test(s[0].type)&&t.parentNode||t))){if(s.splice(a,1),e=i.length&&yt(s),!e)return M.apply(n,i),n;break}}}return l(e,f)(i,t,!h,n,V.test(e)),n}r.sortStable=b.split("").sort(A).join("")===b,r.detectDuplicates=S,p(),r.sortDetached=ut(function(e){return 1&e.compareDocumentPosition(f.createElement("div"))}),ut(function(e){return e.innerHTML="","#"===e.firstChild.getAttribute("href")})||ct("type|href|height|width",function(e,n,r){return r?t:e.getAttribute(n,"type"===n.toLowerCase()?1:2)}),r.attributes&&ut(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||ct("value",function(e,n,r){return r||"input"!==e.nodeName.toLowerCase()?t:e.defaultValue}),ut(function(e){return null==e.getAttribute("disabled")})||ct(B,function(e,n,r){var i;return r?t:(i=e.getAttributeNode(n))&&i.specified?i.value:e[n]===!0?n.toLowerCase():null}),x.find=at,x.expr=at.selectors,x.expr[":"]=x.expr.pseudos,x.unique=at.uniqueSort,x.text=at.getText,x.isXMLDoc=at.isXML,x.contains=at.contains}(e);var O={};function F(e){var t=O[e]={};return x.each(e.match(T)||[],function(e,n){t[n]=!0}),t}x.Callbacks=function(e){e="string"==typeof e?O[e]||F(e):x.extend({},e);var n,r,i,o,a,s,l=[],u=!e.once&&[],c=function(t){for(r=e.memory&&t,i=!0,a=s||0,s=0,o=l.length,n=!0;l&&o>a;a++)if(l[a].apply(t[0],t[1])===!1&&e.stopOnFalse){r=!1;break}n=!1,l&&(u?u.length&&c(u.shift()):r?l=[]:p.disable())},p={add:function(){if(l){var t=l.length;(function i(t){x.each(t,function(t,n){var r=x.type(n);"function"===r?e.unique&&p.has(n)||l.push(n):n&&n.length&&"string"!==r&&i(n)})})(arguments),n?o=l.length:r&&(s=t,c(r))}return this},remove:function(){return l&&x.each(arguments,function(e,t){var r;while((r=x.inArray(t,l,r))>-1)l.splice(r,1),n&&(o>=r&&o--,a>=r&&a--)}),this},has:function(e){return e?x.inArray(e,l)>-1:!(!l||!l.length)},empty:function(){return l=[],o=0,this},disable:function(){return l=u=r=t,this},disabled:function(){return!l},lock:function(){return u=t,r||p.disable(),this},locked:function(){return!u},fireWith:function(e,t){return!l||i&&!u||(t=t||[],t=[e,t.slice?t.slice():t],n?u.push(t):c(t)),this},fire:function(){return p.fireWith(this,arguments),this},fired:function(){return!!i}};return p},x.extend({Deferred:function(e){var t=[["resolve","done",x.Callbacks("once memory"),"resolved"],["reject","fail",x.Callbacks("once memory"),"rejected"],["notify","progress",x.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return x.Deferred(function(n){x.each(t,function(t,o){var a=o[0],s=x.isFunction(e[t])&&e[t];i[o[1]](function(){var e=s&&s.apply(this,arguments);e&&x.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[a+"With"](this===r?n.promise():this,s?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?x.extend(e,r):r}},i={};return r.pipe=r.then,x.each(t,function(e,o){var a=o[2],s=o[3];r[o[1]]=a.add,s&&a.add(function(){n=s},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=a.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t=0,n=g.call(arguments),r=n.length,i=1!==r||e&&x.isFunction(e.promise)?r:0,o=1===i?e:x.Deferred(),a=function(e,t,n){return function(r){t[e]=this,n[e]=arguments.length>1?g.call(arguments):r,n===s?o.notifyWith(t,n):--i||o.resolveWith(t,n)}},s,l,u;if(r>1)for(s=Array(r),l=Array(r),u=Array(r);r>t;t++)n[t]&&x.isFunction(n[t].promise)?n[t].promise().done(a(t,u,n)).fail(o.reject).progress(a(t,l,s)):--i;return i||o.resolveWith(u,n),o.promise()}}),x.support=function(t){var n,r,o,s,l,u,c,p,f,d=a.createElement("div");if(d.setAttribute("className","t"),d.innerHTML="
a",n=d.getElementsByTagName("*")||[],r=d.getElementsByTagName("a")[0],!r||!r.style||!n.length)return t;s=a.createElement("select"),u=s.appendChild(a.createElement("option")),o=d.getElementsByTagName("input")[0],r.style.cssText="top:1px;float:left;opacity:.5",t.getSetAttribute="t"!==d.className,t.leadingWhitespace=3===d.firstChild.nodeType,t.tbody=!d.getElementsByTagName("tbody").length,t.htmlSerialize=!!d.getElementsByTagName("link").length,t.style=/top/.test(r.getAttribute("style")),t.hrefNormalized="/a"===r.getAttribute("href"),t.opacity=/^0.5/.test(r.style.opacity),t.cssFloat=!!r.style.cssFloat,t.checkOn=!!o.value,t.optSelected=u.selected,t.enctype=!!a.createElement("form").enctype,t.html5Clone="<:nav>"!==a.createElement("nav").cloneNode(!0).outerHTML,t.inlineBlockNeedsLayout=!1,t.shrinkWrapBlocks=!1,t.pixelPosition=!1,t.deleteExpando=!0,t.noCloneEvent=!0,t.reliableMarginRight=!0,t.boxSizingReliable=!0,o.checked=!0,t.noCloneChecked=o.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!u.disabled;try{delete d.test}catch(h){t.deleteExpando=!1}o=a.createElement("input"),o.setAttribute("value",""),t.input=""===o.getAttribute("value"),o.value="t",o.setAttribute("type","radio"),t.radioValue="t"===o.value,o.setAttribute("checked","t"),o.setAttribute("name","t"),l=a.createDocumentFragment(),l.appendChild(o),t.appendChecked=o.checked,t.checkClone=l.cloneNode(!0).cloneNode(!0).lastChild.checked,d.attachEvent&&(d.attachEvent("onclick",function(){t.noCloneEvent=!1}),d.cloneNode(!0).click());for(f in{submit:!0,change:!0,focusin:!0})d.setAttribute(c="on"+f,"t"),t[f+"Bubbles"]=c in e||d.attributes[c].expando===!1;d.style.backgroundClip="content-box",d.cloneNode(!0).style.backgroundClip="",t.clearCloneStyle="content-box"===d.style.backgroundClip;for(f in x(t))break;return t.ownLast="0"!==f,x(function(){var n,r,o,s="padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;",l=a.getElementsByTagName("body")[0];l&&(n=a.createElement("div"),n.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",l.appendChild(n).appendChild(d),d.innerHTML="
t
",o=d.getElementsByTagName("td"),o[0].style.cssText="padding:0;margin:0;border:0;display:none",p=0===o[0].offsetHeight,o[0].style.display="",o[1].style.display="none",t.reliableHiddenOffsets=p&&0===o[0].offsetHeight,d.innerHTML="",d.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",x.swap(l,null!=l.style.zoom?{zoom:1}:{},function(){t.boxSizing=4===d.offsetWidth}),e.getComputedStyle&&(t.pixelPosition="1%"!==(e.getComputedStyle(d,null)||{}).top,t.boxSizingReliable="4px"===(e.getComputedStyle(d,null)||{width:"4px"}).width,r=d.appendChild(a.createElement("div")),r.style.cssText=d.style.cssText=s,r.style.marginRight=r.style.width="0",d.style.width="1px",t.reliableMarginRight=!parseFloat((e.getComputedStyle(r,null)||{}).marginRight)),typeof d.style.zoom!==i&&(d.innerHTML="",d.style.cssText=s+"width:1px;padding:1px;display:inline;zoom:1",t.inlineBlockNeedsLayout=3===d.offsetWidth,d.style.display="block",d.innerHTML="
",d.firstChild.style.width="5px",t.shrinkWrapBlocks=3!==d.offsetWidth,t.inlineBlockNeedsLayout&&(l.style.zoom=1)),l.removeChild(n),n=d=o=r=null)}),n=s=l=u=r=o=null,t -}({});var B=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,P=/([A-Z])/g;function R(e,n,r,i){if(x.acceptData(e)){var o,a,s=x.expando,l=e.nodeType,u=l?x.cache:e,c=l?e[s]:e[s]&&s;if(c&&u[c]&&(i||u[c].data)||r!==t||"string"!=typeof n)return c||(c=l?e[s]=p.pop()||x.guid++:s),u[c]||(u[c]=l?{}:{toJSON:x.noop}),("object"==typeof n||"function"==typeof n)&&(i?u[c]=x.extend(u[c],n):u[c].data=x.extend(u[c].data,n)),a=u[c],i||(a.data||(a.data={}),a=a.data),r!==t&&(a[x.camelCase(n)]=r),"string"==typeof n?(o=a[n],null==o&&(o=a[x.camelCase(n)])):o=a,o}}function W(e,t,n){if(x.acceptData(e)){var r,i,o=e.nodeType,a=o?x.cache:e,s=o?e[x.expando]:x.expando;if(a[s]){if(t&&(r=n?a[s]:a[s].data)){x.isArray(t)?t=t.concat(x.map(t,x.camelCase)):t in r?t=[t]:(t=x.camelCase(t),t=t in r?[t]:t.split(" ")),i=t.length;while(i--)delete r[t[i]];if(n?!I(r):!x.isEmptyObject(r))return}(n||(delete a[s].data,I(a[s])))&&(o?x.cleanData([e],!0):x.support.deleteExpando||a!=a.window?delete a[s]:a[s]=null)}}}x.extend({cache:{},noData:{applet:!0,embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(e){return e=e.nodeType?x.cache[e[x.expando]]:e[x.expando],!!e&&!I(e)},data:function(e,t,n){return R(e,t,n)},removeData:function(e,t){return W(e,t)},_data:function(e,t,n){return R(e,t,n,!0)},_removeData:function(e,t){return W(e,t,!0)},acceptData:function(e){if(e.nodeType&&1!==e.nodeType&&9!==e.nodeType)return!1;var t=e.nodeName&&x.noData[e.nodeName.toLowerCase()];return!t||t!==!0&&e.getAttribute("classid")===t}}),x.fn.extend({data:function(e,n){var r,i,o=null,a=0,s=this[0];if(e===t){if(this.length&&(o=x.data(s),1===s.nodeType&&!x._data(s,"parsedAttrs"))){for(r=s.attributes;r.length>a;a++)i=r[a].name,0===i.indexOf("data-")&&(i=x.camelCase(i.slice(5)),$(s,i,o[i]));x._data(s,"parsedAttrs",!0)}return o}return"object"==typeof e?this.each(function(){x.data(this,e)}):arguments.length>1?this.each(function(){x.data(this,e,n)}):s?$(s,e,x.data(s,e)):null},removeData:function(e){return this.each(function(){x.removeData(this,e)})}});function $(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(P,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:B.test(r)?x.parseJSON(r):r}catch(o){}x.data(e,n,r)}else r=t}return r}function I(e){var t;for(t in e)if(("data"!==t||!x.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}x.extend({queue:function(e,n,r){var i;return e?(n=(n||"fx")+"queue",i=x._data(e,n),r&&(!i||x.isArray(r)?i=x._data(e,n,x.makeArray(r)):i.push(r)),i||[]):t},dequeue:function(e,t){t=t||"fx";var n=x.queue(e,t),r=n.length,i=n.shift(),o=x._queueHooks(e,t),a=function(){x.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return x._data(e,n)||x._data(e,n,{empty:x.Callbacks("once memory").add(function(){x._removeData(e,t+"queue"),x._removeData(e,n)})})}}),x.fn.extend({queue:function(e,n){var r=2;return"string"!=typeof e&&(n=e,e="fx",r--),r>arguments.length?x.queue(this[0],e):n===t?this:this.each(function(){var t=x.queue(this,e,n);x._queueHooks(this,e),"fx"===e&&"inprogress"!==t[0]&&x.dequeue(this,e)})},dequeue:function(e){return this.each(function(){x.dequeue(this,e)})},delay:function(e,t){return e=x.fx?x.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,n){var r,i=1,o=x.Deferred(),a=this,s=this.length,l=function(){--i||o.resolveWith(a,[a])};"string"!=typeof e&&(n=e,e=t),e=e||"fx";while(s--)r=x._data(a[s],e+"queueHooks"),r&&r.empty&&(i++,r.empty.add(l));return l(),o.promise(n)}});var z,X,U=/[\t\r\n\f]/g,V=/\r/g,Y=/^(?:input|select|textarea|button|object)$/i,J=/^(?:a|area)$/i,G=/^(?:checked|selected)$/i,Q=x.support.getSetAttribute,K=x.support.input;x.fn.extend({attr:function(e,t){return x.access(this,x.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){x.removeAttr(this,e)})},prop:function(e,t){return x.access(this,x.prop,e,t,arguments.length>1)},removeProp:function(e){return e=x.propFix[e]||e,this.each(function(){try{this[e]=t,delete this[e]}catch(n){}})},addClass:function(e){var t,n,r,i,o,a=0,s=this.length,l="string"==typeof e&&e;if(x.isFunction(e))return this.each(function(t){x(this).addClass(e.call(this,t,this.className))});if(l)for(t=(e||"").match(T)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(U," "):" ")){o=0;while(i=t[o++])0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=x.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,a=0,s=this.length,l=0===arguments.length||"string"==typeof e&&e;if(x.isFunction(e))return this.each(function(t){x(this).removeClass(e.call(this,t,this.className))});if(l)for(t=(e||"").match(T)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(U," "):"")){o=0;while(i=t[o++])while(r.indexOf(" "+i+" ")>=0)r=r.replace(" "+i+" "," ");n.className=e?x.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e;return"boolean"==typeof t&&"string"===n?t?this.addClass(e):this.removeClass(e):x.isFunction(e)?this.each(function(n){x(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n){var t,r=0,o=x(this),a=e.match(T)||[];while(t=a[r++])o.hasClass(t)?o.removeClass(t):o.addClass(t)}else(n===i||"boolean"===n)&&(this.className&&x._data(this,"__className__",this.className),this.className=this.className||e===!1?"":x._data(this,"__className__")||"")})},hasClass:function(e){var t=" "+e+" ",n=0,r=this.length;for(;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(U," ").indexOf(t)>=0)return!0;return!1},val:function(e){var n,r,i,o=this[0];{if(arguments.length)return i=x.isFunction(e),this.each(function(n){var o;1===this.nodeType&&(o=i?e.call(this,n,x(this).val()):e,null==o?o="":"number"==typeof o?o+="":x.isArray(o)&&(o=x.map(o,function(e){return null==e?"":e+""})),r=x.valHooks[this.type]||x.valHooks[this.nodeName.toLowerCase()],r&&"set"in r&&r.set(this,o,"value")!==t||(this.value=o))});if(o)return r=x.valHooks[o.type]||x.valHooks[o.nodeName.toLowerCase()],r&&"get"in r&&(n=r.get(o,"value"))!==t?n:(n=o.value,"string"==typeof n?n.replace(V,""):null==n?"":n)}}}),x.extend({valHooks:{option:{get:function(e){var t=x.find.attr(e,"value");return null!=t?t:e.text}},select:{get:function(e){var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,a=o?null:[],s=o?i+1:r.length,l=0>i?s:o?i:0;for(;s>l;l++)if(n=r[l],!(!n.selected&&l!==i||(x.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&x.nodeName(n.parentNode,"optgroup"))){if(t=x(n).val(),o)return t;a.push(t)}return a},set:function(e,t){var n,r,i=e.options,o=x.makeArray(t),a=i.length;while(a--)r=i[a],(r.selected=x.inArray(x(r).val(),o)>=0)&&(n=!0);return n||(e.selectedIndex=-1),o}}},attr:function(e,n,r){var o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return typeof e.getAttribute===i?x.prop(e,n,r):(1===s&&x.isXMLDoc(e)||(n=n.toLowerCase(),o=x.attrHooks[n]||(x.expr.match.bool.test(n)?X:z)),r===t?o&&"get"in o&&null!==(a=o.get(e,n))?a:(a=x.find.attr(e,n),null==a?t:a):null!==r?o&&"set"in o&&(a=o.set(e,r,n))!==t?a:(e.setAttribute(n,r+""),r):(x.removeAttr(e,n),t))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(T);if(o&&1===e.nodeType)while(n=o[i++])r=x.propFix[n]||n,x.expr.match.bool.test(n)?K&&Q||!G.test(n)?e[r]=!1:e[x.camelCase("default-"+n)]=e[r]=!1:x.attr(e,n,""),e.removeAttribute(Q?n:r)},attrHooks:{type:{set:function(e,t){if(!x.support.radioValue&&"radio"===t&&x.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{"for":"htmlFor","class":"className"},prop:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return a=1!==s||!x.isXMLDoc(e),a&&(n=x.propFix[n]||n,o=x.propHooks[n]),r!==t?o&&"set"in o&&(i=o.set(e,r,n))!==t?i:e[n]=r:o&&"get"in o&&null!==(i=o.get(e,n))?i:e[n]},propHooks:{tabIndex:{get:function(e){var t=x.find.attr(e,"tabindex");return t?parseInt(t,10):Y.test(e.nodeName)||J.test(e.nodeName)&&e.href?0:-1}}}}),X={set:function(e,t,n){return t===!1?x.removeAttr(e,n):K&&Q||!G.test(n)?e.setAttribute(!Q&&x.propFix[n]||n,n):e[x.camelCase("default-"+n)]=e[n]=!0,n}},x.each(x.expr.match.bool.source.match(/\w+/g),function(e,n){var r=x.expr.attrHandle[n]||x.find.attr;x.expr.attrHandle[n]=K&&Q||!G.test(n)?function(e,n,i){var o=x.expr.attrHandle[n],a=i?t:(x.expr.attrHandle[n]=t)!=r(e,n,i)?n.toLowerCase():null;return x.expr.attrHandle[n]=o,a}:function(e,n,r){return r?t:e[x.camelCase("default-"+n)]?n.toLowerCase():null}}),K&&Q||(x.attrHooks.value={set:function(e,n,r){return x.nodeName(e,"input")?(e.defaultValue=n,t):z&&z.set(e,n,r)}}),Q||(z={set:function(e,n,r){var i=e.getAttributeNode(r);return i||e.setAttributeNode(i=e.ownerDocument.createAttribute(r)),i.value=n+="","value"===r||n===e.getAttribute(r)?n:t}},x.expr.attrHandle.id=x.expr.attrHandle.name=x.expr.attrHandle.coords=function(e,n,r){var i;return r?t:(i=e.getAttributeNode(n))&&""!==i.value?i.value:null},x.valHooks.button={get:function(e,n){var r=e.getAttributeNode(n);return r&&r.specified?r.value:t},set:z.set},x.attrHooks.contenteditable={set:function(e,t,n){z.set(e,""===t?!1:t,n)}},x.each(["width","height"],function(e,n){x.attrHooks[n]={set:function(e,r){return""===r?(e.setAttribute(n,"auto"),r):t}}})),x.support.hrefNormalized||x.each(["href","src"],function(e,t){x.propHooks[t]={get:function(e){return e.getAttribute(t,4)}}}),x.support.style||(x.attrHooks.style={get:function(e){return e.style.cssText||t},set:function(e,t){return e.style.cssText=t+""}}),x.support.optSelected||(x.propHooks.selected={get:function(e){var t=e.parentNode;return t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex),null}}),x.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){x.propFix[this.toLowerCase()]=this}),x.support.enctype||(x.propFix.enctype="encoding"),x.each(["radio","checkbox"],function(){x.valHooks[this]={set:function(e,n){return x.isArray(n)?e.checked=x.inArray(x(e).val(),n)>=0:t}},x.support.checkOn||(x.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})});var Z=/^(?:input|select|textarea)$/i,et=/^key/,tt=/^(?:mouse|contextmenu)|click/,nt=/^(?:focusinfocus|focusoutblur)$/,rt=/^([^.]*)(?:\.(.+)|)$/;function it(){return!0}function ot(){return!1}function at(){try{return a.activeElement}catch(e){}}x.event={global:{},add:function(e,n,r,o,a){var s,l,u,c,p,f,d,h,g,m,y,v=x._data(e);if(v){r.handler&&(c=r,r=c.handler,a=c.selector),r.guid||(r.guid=x.guid++),(l=v.events)||(l=v.events={}),(f=v.handle)||(f=v.handle=function(e){return typeof x===i||e&&x.event.triggered===e.type?t:x.event.dispatch.apply(f.elem,arguments)},f.elem=e),n=(n||"").match(T)||[""],u=n.length;while(u--)s=rt.exec(n[u])||[],g=y=s[1],m=(s[2]||"").split(".").sort(),g&&(p=x.event.special[g]||{},g=(a?p.delegateType:p.bindType)||g,p=x.event.special[g]||{},d=x.extend({type:g,origType:y,data:o,handler:r,guid:r.guid,selector:a,needsContext:a&&x.expr.match.needsContext.test(a),namespace:m.join(".")},c),(h=l[g])||(h=l[g]=[],h.delegateCount=0,p.setup&&p.setup.call(e,o,m,f)!==!1||(e.addEventListener?e.addEventListener(g,f,!1):e.attachEvent&&e.attachEvent("on"+g,f))),p.add&&(p.add.call(e,d),d.handler.guid||(d.handler.guid=r.guid)),a?h.splice(h.delegateCount++,0,d):h.push(d),x.event.global[g]=!0);e=null}},remove:function(e,t,n,r,i){var o,a,s,l,u,c,p,f,d,h,g,m=x.hasData(e)&&x._data(e);if(m&&(c=m.events)){t=(t||"").match(T)||[""],u=t.length;while(u--)if(s=rt.exec(t[u])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){p=x.event.special[d]||{},d=(r?p.delegateType:p.bindType)||d,f=c[d]||[],s=s[2]&&RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),l=o=f.length;while(o--)a=f[o],!i&&g!==a.origType||n&&n.guid!==a.guid||s&&!s.test(a.namespace)||r&&r!==a.selector&&("**"!==r||!a.selector)||(f.splice(o,1),a.selector&&f.delegateCount--,p.remove&&p.remove.call(e,a));l&&!f.length&&(p.teardown&&p.teardown.call(e,h,m.handle)!==!1||x.removeEvent(e,d,m.handle),delete c[d])}else for(d in c)x.event.remove(e,d+t[u],n,r,!0);x.isEmptyObject(c)&&(delete m.handle,x._removeData(e,"events"))}},trigger:function(n,r,i,o){var s,l,u,c,p,f,d,h=[i||a],g=v.call(n,"type")?n.type:n,m=v.call(n,"namespace")?n.namespace.split("."):[];if(u=f=i=i||a,3!==i.nodeType&&8!==i.nodeType&&!nt.test(g+x.event.triggered)&&(g.indexOf(".")>=0&&(m=g.split("."),g=m.shift(),m.sort()),l=0>g.indexOf(":")&&"on"+g,n=n[x.expando]?n:new x.Event(g,"object"==typeof n&&n),n.isTrigger=o?2:3,n.namespace=m.join("."),n.namespace_re=n.namespace?RegExp("(^|\\.)"+m.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,n.result=t,n.target||(n.target=i),r=null==r?[n]:x.makeArray(r,[n]),p=x.event.special[g]||{},o||!p.trigger||p.trigger.apply(i,r)!==!1)){if(!o&&!p.noBubble&&!x.isWindow(i)){for(c=p.delegateType||g,nt.test(c+g)||(u=u.parentNode);u;u=u.parentNode)h.push(u),f=u;f===(i.ownerDocument||a)&&h.push(f.defaultView||f.parentWindow||e)}d=0;while((u=h[d++])&&!n.isPropagationStopped())n.type=d>1?c:p.bindType||g,s=(x._data(u,"events")||{})[n.type]&&x._data(u,"handle"),s&&s.apply(u,r),s=l&&u[l],s&&x.acceptData(u)&&s.apply&&s.apply(u,r)===!1&&n.preventDefault();if(n.type=g,!o&&!n.isDefaultPrevented()&&(!p._default||p._default.apply(h.pop(),r)===!1)&&x.acceptData(i)&&l&&i[g]&&!x.isWindow(i)){f=i[l],f&&(i[l]=null),x.event.triggered=g;try{i[g]()}catch(y){}x.event.triggered=t,f&&(i[l]=f)}return n.result}},dispatch:function(e){e=x.event.fix(e);var n,r,i,o,a,s=[],l=g.call(arguments),u=(x._data(this,"events")||{})[e.type]||[],c=x.event.special[e.type]||{};if(l[0]=e,e.delegateTarget=this,!c.preDispatch||c.preDispatch.call(this,e)!==!1){s=x.event.handlers.call(this,e,u),n=0;while((o=s[n++])&&!e.isPropagationStopped()){e.currentTarget=o.elem,a=0;while((i=o.handlers[a++])&&!e.isImmediatePropagationStopped())(!e.namespace_re||e.namespace_re.test(i.namespace))&&(e.handleObj=i,e.data=i.data,r=((x.event.special[i.origType]||{}).handle||i.handler).apply(o.elem,l),r!==t&&(e.result=r)===!1&&(e.preventDefault(),e.stopPropagation()))}return c.postDispatch&&c.postDispatch.call(this,e),e.result}},handlers:function(e,n){var r,i,o,a,s=[],l=n.delegateCount,u=e.target;if(l&&u.nodeType&&(!e.button||"click"!==e.type))for(;u!=this;u=u.parentNode||this)if(1===u.nodeType&&(u.disabled!==!0||"click"!==e.type)){for(o=[],a=0;l>a;a++)i=n[a],r=i.selector+" ",o[r]===t&&(o[r]=i.needsContext?x(r,this).index(u)>=0:x.find(r,this,null,[u]).length),o[r]&&o.push(i);o.length&&s.push({elem:u,handlers:o})}return n.length>l&&s.push({elem:this,handlers:n.slice(l)}),s},fix:function(e){if(e[x.expando])return e;var t,n,r,i=e.type,o=e,s=this.fixHooks[i];s||(this.fixHooks[i]=s=tt.test(i)?this.mouseHooks:et.test(i)?this.keyHooks:{}),r=s.props?this.props.concat(s.props):this.props,e=new x.Event(o),t=r.length;while(t--)n=r[t],e[n]=o[n];return e.target||(e.target=o.srcElement||a),3===e.target.nodeType&&(e.target=e.target.parentNode),e.metaKey=!!e.metaKey,s.filter?s.filter(e,o):e},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,n){var r,i,o,s=n.button,l=n.fromElement;return null==e.pageX&&null!=n.clientX&&(i=e.target.ownerDocument||a,o=i.documentElement,r=i.body,e.pageX=n.clientX+(o&&o.scrollLeft||r&&r.scrollLeft||0)-(o&&o.clientLeft||r&&r.clientLeft||0),e.pageY=n.clientY+(o&&o.scrollTop||r&&r.scrollTop||0)-(o&&o.clientTop||r&&r.clientTop||0)),!e.relatedTarget&&l&&(e.relatedTarget=l===e.target?n.toElement:l),e.which||s===t||(e.which=1&s?1:2&s?3:4&s?2:0),e}},special:{load:{noBubble:!0},focus:{trigger:function(){if(this!==at()&&this.focus)try{return this.focus(),!1}catch(e){}},delegateType:"focusin"},blur:{trigger:function(){return this===at()&&this.blur?(this.blur(),!1):t},delegateType:"focusout"},click:{trigger:function(){return x.nodeName(this,"input")&&"checkbox"===this.type&&this.click?(this.click(),!1):t},_default:function(e){return x.nodeName(e.target,"a")}},beforeunload:{postDispatch:function(e){e.result!==t&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=x.extend(new x.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?x.event.trigger(i,null,t):x.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},x.removeEvent=a.removeEventListener?function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)}:function(e,t,n){var r="on"+t;e.detachEvent&&(typeof e[r]===i&&(e[r]=null),e.detachEvent(r,n))},x.Event=function(e,n){return this instanceof x.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.returnValue===!1||e.getPreventDefault&&e.getPreventDefault()?it:ot):this.type=e,n&&x.extend(this,n),this.timeStamp=e&&e.timeStamp||x.now(),this[x.expando]=!0,t):new x.Event(e,n)},x.Event.prototype={isDefaultPrevented:ot,isPropagationStopped:ot,isImmediatePropagationStopped:ot,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=it,e&&(e.preventDefault?e.preventDefault():e.returnValue=!1)},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=it,e&&(e.stopPropagation&&e.stopPropagation(),e.cancelBubble=!0)},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=it,this.stopPropagation()}},x.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){x.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj;return(!i||i!==r&&!x.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),x.support.submitBubbles||(x.event.special.submit={setup:function(){return x.nodeName(this,"form")?!1:(x.event.add(this,"click._submit keypress._submit",function(e){var n=e.target,r=x.nodeName(n,"input")||x.nodeName(n,"button")?n.form:t;r&&!x._data(r,"submitBubbles")&&(x.event.add(r,"submit._submit",function(e){e._submit_bubble=!0}),x._data(r,"submitBubbles",!0))}),t)},postDispatch:function(e){e._submit_bubble&&(delete e._submit_bubble,this.parentNode&&!e.isTrigger&&x.event.simulate("submit",this.parentNode,e,!0))},teardown:function(){return x.nodeName(this,"form")?!1:(x.event.remove(this,"._submit"),t)}}),x.support.changeBubbles||(x.event.special.change={setup:function(){return Z.test(this.nodeName)?(("checkbox"===this.type||"radio"===this.type)&&(x.event.add(this,"propertychange._change",function(e){"checked"===e.originalEvent.propertyName&&(this._just_changed=!0)}),x.event.add(this,"click._change",function(e){this._just_changed&&!e.isTrigger&&(this._just_changed=!1),x.event.simulate("change",this,e,!0)})),!1):(x.event.add(this,"beforeactivate._change",function(e){var t=e.target;Z.test(t.nodeName)&&!x._data(t,"changeBubbles")&&(x.event.add(t,"change._change",function(e){!this.parentNode||e.isSimulated||e.isTrigger||x.event.simulate("change",this.parentNode,e,!0)}),x._data(t,"changeBubbles",!0))}),t)},handle:function(e){var n=e.target;return this!==n||e.isSimulated||e.isTrigger||"radio"!==n.type&&"checkbox"!==n.type?e.handleObj.handler.apply(this,arguments):t},teardown:function(){return x.event.remove(this,"._change"),!Z.test(this.nodeName)}}),x.support.focusinBubbles||x.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){x.event.simulate(t,e.target,x.event.fix(e),!0)};x.event.special[t]={setup:function(){0===n++&&a.addEventListener(e,r,!0)},teardown:function(){0===--n&&a.removeEventListener(e,r,!0)}}}),x.fn.extend({on:function(e,n,r,i,o){var a,s;if("object"==typeof e){"string"!=typeof n&&(r=r||n,n=t);for(a in e)this.on(a,n,r,e[a],o);return this}if(null==r&&null==i?(i=n,r=n=t):null==i&&("string"==typeof n?(i=r,r=t):(i=r,r=n,n=t)),i===!1)i=ot;else if(!i)return this;return 1===o&&(s=i,i=function(e){return x().off(e),s.apply(this,arguments)},i.guid=s.guid||(s.guid=x.guid++)),this.each(function(){x.event.add(this,e,i,r,n)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,n,r){var i,o;if(e&&e.preventDefault&&e.handleObj)return i=e.handleObj,x(e.delegateTarget).off(i.namespace?i.origType+"."+i.namespace:i.origType,i.selector,i.handler),this;if("object"==typeof e){for(o in e)this.off(o,n,e[o]);return this}return(n===!1||"function"==typeof n)&&(r=n,n=t),r===!1&&(r=ot),this.each(function(){x.event.remove(this,e,r,n)})},trigger:function(e,t){return this.each(function(){x.event.trigger(e,t,this)})},triggerHandler:function(e,n){var r=this[0];return r?x.event.trigger(e,n,r,!0):t}});var st=/^.[^:#\[\.,]*$/,lt=/^(?:parents|prev(?:Until|All))/,ut=x.expr.match.needsContext,ct={children:!0,contents:!0,next:!0,prev:!0};x.fn.extend({find:function(e){var t,n=[],r=this,i=r.length;if("string"!=typeof e)return this.pushStack(x(e).filter(function(){for(t=0;i>t;t++)if(x.contains(r[t],this))return!0}));for(t=0;i>t;t++)x.find(e,r[t],n);return n=this.pushStack(i>1?x.unique(n):n),n.selector=this.selector?this.selector+" "+e:e,n},has:function(e){var t,n=x(e,this),r=n.length;return this.filter(function(){for(t=0;r>t;t++)if(x.contains(this,n[t]))return!0})},not:function(e){return this.pushStack(ft(this,e||[],!0))},filter:function(e){return this.pushStack(ft(this,e||[],!1))},is:function(e){return!!ft(this,"string"==typeof e&&ut.test(e)?x(e):e||[],!1).length},closest:function(e,t){var n,r=0,i=this.length,o=[],a=ut.test(e)||"string"!=typeof e?x(e,t||this.context):0;for(;i>r;r++)for(n=this[r];n&&n!==t;n=n.parentNode)if(11>n.nodeType&&(a?a.index(n)>-1:1===n.nodeType&&x.find.matchesSelector(n,e))){n=o.push(n);break}return this.pushStack(o.length>1?x.unique(o):o)},index:function(e){return e?"string"==typeof e?x.inArray(this[0],x(e)):x.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?x(e,t):x.makeArray(e&&e.nodeType?[e]:e),r=x.merge(this.get(),n);return this.pushStack(x.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function pt(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}x.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return x.dir(e,"parentNode")},parentsUntil:function(e,t,n){return x.dir(e,"parentNode",n)},next:function(e){return pt(e,"nextSibling")},prev:function(e){return pt(e,"previousSibling")},nextAll:function(e){return x.dir(e,"nextSibling")},prevAll:function(e){return x.dir(e,"previousSibling")},nextUntil:function(e,t,n){return x.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return x.dir(e,"previousSibling",n)},siblings:function(e){return x.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return x.sibling(e.firstChild)},contents:function(e){return x.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:x.merge([],e.childNodes)}},function(e,t){x.fn[e]=function(n,r){var i=x.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=x.filter(r,i)),this.length>1&&(ct[e]||(i=x.unique(i)),lt.test(e)&&(i=i.reverse())),this.pushStack(i)}}),x.extend({filter:function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?x.find.matchesSelector(r,e)?[r]:[]:x.find.matches(e,x.grep(t,function(e){return 1===e.nodeType}))},dir:function(e,n,r){var i=[],o=e[n];while(o&&9!==o.nodeType&&(r===t||1!==o.nodeType||!x(o).is(r)))1===o.nodeType&&i.push(o),o=o[n];return i},sibling:function(e,t){var n=[];for(;e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});function ft(e,t,n){if(x.isFunction(t))return x.grep(e,function(e,r){return!!t.call(e,r,e)!==n});if(t.nodeType)return x.grep(e,function(e){return e===t!==n});if("string"==typeof t){if(st.test(t))return x.filter(t,e,n);t=x.filter(t,e)}return x.grep(e,function(e){return x.inArray(e,t)>=0!==n})}function dt(e){var t=ht.split("|"),n=e.createDocumentFragment();if(n.createElement)while(t.length)n.createElement(t.pop());return n}var ht="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",gt=/ jQuery\d+="(?:null|\d+)"/g,mt=RegExp("<(?:"+ht+")[\\s/>]","i"),yt=/^\s+/,vt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,bt=/<([\w:]+)/,xt=/\s*$/g,At={option:[1,""],legend:[1,"
","
"],area:[1,"",""],param:[1,"",""],thead:[1,"","
"],tr:[2,"","
"],col:[2,"","
"],td:[3,"","
"],_default:x.support.htmlSerialize?[0,"",""]:[1,"X
","
"]},jt=dt(a),Dt=jt.appendChild(a.createElement("div"));At.optgroup=At.option,At.tbody=At.tfoot=At.colgroup=At.caption=At.thead,At.th=At.td,x.fn.extend({text:function(e){return x.access(this,function(e){return e===t?x.text(this):this.empty().append((this[0]&&this[0].ownerDocument||a).createTextNode(e))},null,e,arguments.length)},append:function(){return this.domManip(arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Lt(this,e);t.appendChild(e)}})},prepend:function(){return this.domManip(arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Lt(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return this.domManip(arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){var n,r=e?x.filter(e,this):this,i=0;for(;null!=(n=r[i]);i++)t||1!==n.nodeType||x.cleanData(Ft(n)),n.parentNode&&(t&&x.contains(n.ownerDocument,n)&&_t(Ft(n,"script")),n.parentNode.removeChild(n));return this},empty:function(){var e,t=0;for(;null!=(e=this[t]);t++){1===e.nodeType&&x.cleanData(Ft(e,!1));while(e.firstChild)e.removeChild(e.firstChild);e.options&&x.nodeName(e,"select")&&(e.options.length=0)}return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return x.clone(this,e,t)})},html:function(e){return x.access(this,function(e){var n=this[0]||{},r=0,i=this.length;if(e===t)return 1===n.nodeType?n.innerHTML.replace(gt,""):t;if(!("string"!=typeof e||Tt.test(e)||!x.support.htmlSerialize&&mt.test(e)||!x.support.leadingWhitespace&&yt.test(e)||At[(bt.exec(e)||["",""])[1].toLowerCase()])){e=e.replace(vt,"<$1>");try{for(;i>r;r++)n=this[r]||{},1===n.nodeType&&(x.cleanData(Ft(n,!1)),n.innerHTML=e);n=0}catch(o){}}n&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(){var e=x.map(this,function(e){return[e.nextSibling,e.parentNode]}),t=0;return this.domManip(arguments,function(n){var r=e[t++],i=e[t++];i&&(r&&r.parentNode!==i&&(r=this.nextSibling),x(this).remove(),i.insertBefore(n,r))},!0),t?this:this.remove()},detach:function(e){return this.remove(e,!0)},domManip:function(e,t,n){e=d.apply([],e);var r,i,o,a,s,l,u=0,c=this.length,p=this,f=c-1,h=e[0],g=x.isFunction(h);if(g||!(1>=c||"string"!=typeof h||x.support.checkClone)&&Nt.test(h))return this.each(function(r){var i=p.eq(r);g&&(e[0]=h.call(this,r,i.html())),i.domManip(e,t,n)});if(c&&(l=x.buildFragment(e,this[0].ownerDocument,!1,!n&&this),r=l.firstChild,1===l.childNodes.length&&(l=r),r)){for(a=x.map(Ft(l,"script"),Ht),o=a.length;c>u;u++)i=l,u!==f&&(i=x.clone(i,!0,!0),o&&x.merge(a,Ft(i,"script"))),t.call(this[u],i,u);if(o)for(s=a[a.length-1].ownerDocument,x.map(a,qt),u=0;o>u;u++)i=a[u],kt.test(i.type||"")&&!x._data(i,"globalEval")&&x.contains(s,i)&&(i.src?x._evalUrl(i.src):x.globalEval((i.text||i.textContent||i.innerHTML||"").replace(St,"")));l=r=null}return this}});function Lt(e,t){return x.nodeName(e,"table")&&x.nodeName(1===t.nodeType?t:t.firstChild,"tr")?e.getElementsByTagName("tbody")[0]||e.appendChild(e.ownerDocument.createElement("tbody")):e}function Ht(e){return e.type=(null!==x.find.attr(e,"type"))+"/"+e.type,e}function qt(e){var t=Et.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function _t(e,t){var n,r=0;for(;null!=(n=e[r]);r++)x._data(n,"globalEval",!t||x._data(t[r],"globalEval"))}function Mt(e,t){if(1===t.nodeType&&x.hasData(e)){var n,r,i,o=x._data(e),a=x._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)x.event.add(t,n,s[n][r])}a.data&&(a.data=x.extend({},a.data))}}function Ot(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!x.support.noCloneEvent&&t[x.expando]){i=x._data(t);for(r in i.events)x.removeEvent(t,r,i.handle);t.removeAttribute(x.expando)}"script"===n&&t.text!==e.text?(Ht(t).text=e.text,qt(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),x.support.html5Clone&&e.innerHTML&&!x.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Ct.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}x.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){x.fn[e]=function(e){var n,r=0,i=[],o=x(e),a=o.length-1;for(;a>=r;r++)n=r===a?this:this.clone(!0),x(o[r])[t](n),h.apply(i,n.get());return this.pushStack(i)}});function Ft(e,n){var r,o,a=0,s=typeof e.getElementsByTagName!==i?e.getElementsByTagName(n||"*"):typeof e.querySelectorAll!==i?e.querySelectorAll(n||"*"):t;if(!s)for(s=[],r=e.childNodes||e;null!=(o=r[a]);a++)!n||x.nodeName(o,n)?s.push(o):x.merge(s,Ft(o,n));return n===t||n&&x.nodeName(e,n)?x.merge([e],s):s}function Bt(e){Ct.test(e.type)&&(e.defaultChecked=e.checked)}x.extend({clone:function(e,t,n){var r,i,o,a,s,l=x.contains(e.ownerDocument,e);if(x.support.html5Clone||x.isXMLDoc(e)||!mt.test("<"+e.nodeName+">")?o=e.cloneNode(!0):(Dt.innerHTML=e.outerHTML,Dt.removeChild(o=Dt.firstChild)),!(x.support.noCloneEvent&&x.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||x.isXMLDoc(e)))for(r=Ft(o),s=Ft(e),a=0;null!=(i=s[a]);++a)r[a]&&Ot(i,r[a]);if(t)if(n)for(s=s||Ft(e),r=r||Ft(o),a=0;null!=(i=s[a]);a++)Mt(i,r[a]);else Mt(e,o);return r=Ft(o,"script"),r.length>0&&_t(r,!l&&Ft(e,"script")),r=s=i=null,o},buildFragment:function(e,t,n,r){var i,o,a,s,l,u,c,p=e.length,f=dt(t),d=[],h=0;for(;p>h;h++)if(o=e[h],o||0===o)if("object"===x.type(o))x.merge(d,o.nodeType?[o]:o);else if(wt.test(o)){s=s||f.appendChild(t.createElement("div")),l=(bt.exec(o)||["",""])[1].toLowerCase(),c=At[l]||At._default,s.innerHTML=c[1]+o.replace(vt,"<$1>")+c[2],i=c[0];while(i--)s=s.lastChild;if(!x.support.leadingWhitespace&&yt.test(o)&&d.push(t.createTextNode(yt.exec(o)[0])),!x.support.tbody){o="table"!==l||xt.test(o)?""!==c[1]||xt.test(o)?0:s:s.firstChild,i=o&&o.childNodes.length;while(i--)x.nodeName(u=o.childNodes[i],"tbody")&&!u.childNodes.length&&o.removeChild(u)}x.merge(d,s.childNodes),s.textContent="";while(s.firstChild)s.removeChild(s.firstChild);s=f.lastChild}else d.push(t.createTextNode(o));s&&f.removeChild(s),x.support.appendChecked||x.grep(Ft(d,"input"),Bt),h=0;while(o=d[h++])if((!r||-1===x.inArray(o,r))&&(a=x.contains(o.ownerDocument,o),s=Ft(f.appendChild(o),"script"),a&&_t(s),n)){i=0;while(o=s[i++])kt.test(o.type||"")&&n.push(o)}return s=null,f},cleanData:function(e,t){var n,r,o,a,s=0,l=x.expando,u=x.cache,c=x.support.deleteExpando,f=x.event.special;for(;null!=(n=e[s]);s++)if((t||x.acceptData(n))&&(o=n[l],a=o&&u[o])){if(a.events)for(r in a.events)f[r]?x.event.remove(n,r):x.removeEvent(n,r,a.handle); -u[o]&&(delete u[o],c?delete n[l]:typeof n.removeAttribute!==i?n.removeAttribute(l):n[l]=null,p.push(o))}},_evalUrl:function(e){return x.ajax({url:e,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0})}}),x.fn.extend({wrapAll:function(e){if(x.isFunction(e))return this.each(function(t){x(this).wrapAll(e.call(this,t))});if(this[0]){var t=x(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstChild&&1===e.firstChild.nodeType)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return x.isFunction(e)?this.each(function(t){x(this).wrapInner(e.call(this,t))}):this.each(function(){var t=x(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=x.isFunction(e);return this.each(function(n){x(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){x.nodeName(this,"body")||x(this).replaceWith(this.childNodes)}).end()}});var Pt,Rt,Wt,$t=/alpha\([^)]*\)/i,It=/opacity\s*=\s*([^)]*)/,zt=/^(top|right|bottom|left)$/,Xt=/^(none|table(?!-c[ea]).+)/,Ut=/^margin/,Vt=RegExp("^("+w+")(.*)$","i"),Yt=RegExp("^("+w+")(?!px)[a-z%]+$","i"),Jt=RegExp("^([+-])=("+w+")","i"),Gt={BODY:"block"},Qt={position:"absolute",visibility:"hidden",display:"block"},Kt={letterSpacing:0,fontWeight:400},Zt=["Top","Right","Bottom","Left"],en=["Webkit","O","Moz","ms"];function tn(e,t){if(t in e)return t;var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=en.length;while(i--)if(t=en[i]+n,t in e)return t;return r}function nn(e,t){return e=t||e,"none"===x.css(e,"display")||!x.contains(e.ownerDocument,e)}function rn(e,t){var n,r,i,o=[],a=0,s=e.length;for(;s>a;a++)r=e[a],r.style&&(o[a]=x._data(r,"olddisplay"),n=r.style.display,t?(o[a]||"none"!==n||(r.style.display=""),""===r.style.display&&nn(r)&&(o[a]=x._data(r,"olddisplay",ln(r.nodeName)))):o[a]||(i=nn(r),(n&&"none"!==n||!i)&&x._data(r,"olddisplay",i?n:x.css(r,"display"))));for(a=0;s>a;a++)r=e[a],r.style&&(t&&"none"!==r.style.display&&""!==r.style.display||(r.style.display=t?o[a]||"":"none"));return e}x.fn.extend({css:function(e,n){return x.access(this,function(e,n,r){var i,o,a={},s=0;if(x.isArray(n)){for(o=Rt(e),i=n.length;i>s;s++)a[n[s]]=x.css(e,n[s],!1,o);return a}return r!==t?x.style(e,n,r):x.css(e,n)},e,n,arguments.length>1)},show:function(){return rn(this,!0)},hide:function(){return rn(this)},toggle:function(e){return"boolean"==typeof e?e?this.show():this.hide():this.each(function(){nn(this)?x(this).show():x(this).hide()})}}),x.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Wt(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":x.support.cssFloat?"cssFloat":"styleFloat"},style:function(e,n,r,i){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var o,a,s,l=x.camelCase(n),u=e.style;if(n=x.cssProps[l]||(x.cssProps[l]=tn(u,l)),s=x.cssHooks[n]||x.cssHooks[l],r===t)return s&&"get"in s&&(o=s.get(e,!1,i))!==t?o:u[n];if(a=typeof r,"string"===a&&(o=Jt.exec(r))&&(r=(o[1]+1)*o[2]+parseFloat(x.css(e,n)),a="number"),!(null==r||"number"===a&&isNaN(r)||("number"!==a||x.cssNumber[l]||(r+="px"),x.support.clearCloneStyle||""!==r||0!==n.indexOf("background")||(u[n]="inherit"),s&&"set"in s&&(r=s.set(e,r,i))===t)))try{u[n]=r}catch(c){}}},css:function(e,n,r,i){var o,a,s,l=x.camelCase(n);return n=x.cssProps[l]||(x.cssProps[l]=tn(e.style,l)),s=x.cssHooks[n]||x.cssHooks[l],s&&"get"in s&&(a=s.get(e,!0,r)),a===t&&(a=Wt(e,n,i)),"normal"===a&&n in Kt&&(a=Kt[n]),""===r||r?(o=parseFloat(a),r===!0||x.isNumeric(o)?o||0:a):a}}),e.getComputedStyle?(Rt=function(t){return e.getComputedStyle(t,null)},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),l=s?s.getPropertyValue(n)||s[n]:t,u=e.style;return s&&(""!==l||x.contains(e.ownerDocument,e)||(l=x.style(e,n)),Yt.test(l)&&Ut.test(n)&&(i=u.width,o=u.minWidth,a=u.maxWidth,u.minWidth=u.maxWidth=u.width=l,l=s.width,u.width=i,u.minWidth=o,u.maxWidth=a)),l}):a.documentElement.currentStyle&&(Rt=function(e){return e.currentStyle},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),l=s?s[n]:t,u=e.style;return null==l&&u&&u[n]&&(l=u[n]),Yt.test(l)&&!zt.test(n)&&(i=u.left,o=e.runtimeStyle,a=o&&o.left,a&&(o.left=e.currentStyle.left),u.left="fontSize"===n?"1em":l,l=u.pixelLeft+"px",u.left=i,a&&(o.left=a)),""===l?"auto":l});function on(e,t,n){var r=Vt.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function an(e,t,n,r,i){var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;for(;4>o;o+=2)"margin"===n&&(a+=x.css(e,n+Zt[o],!0,i)),r?("content"===n&&(a-=x.css(e,"padding"+Zt[o],!0,i)),"margin"!==n&&(a-=x.css(e,"border"+Zt[o]+"Width",!0,i))):(a+=x.css(e,"padding"+Zt[o],!0,i),"padding"!==n&&(a+=x.css(e,"border"+Zt[o]+"Width",!0,i)));return a}function sn(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=Rt(e),a=x.support.boxSizing&&"border-box"===x.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=Wt(e,t,o),(0>i||null==i)&&(i=e.style[t]),Yt.test(i))return i;r=a&&(x.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+an(e,t,n||(a?"border":"content"),r,o)+"px"}function ln(e){var t=a,n=Gt[e];return n||(n=un(e,t),"none"!==n&&n||(Pt=(Pt||x("'); - frame.attr('src', url); - $('body').append(frame); -} - -function uploadFile(uri, input, callback) { - if (!window.FormData) { - showErrorMessage("Your browser doesn't implement this function!");s - callback(); - } - - var formData = new FormData(); - var files = input[0].files; - formData.append('files', files[0], files[0].name); - - ajax('POST', uri, formData, callback); -} - - - /* apply button */ - -function showApply() { - var applyButton = $('#applyButton'); - - applyButton.html('Apply'); - applyButton.css('display', 'inline-block'); - applyButton.removeClass('progress'); - setTimeout(function () { - applyButton.css('opacity', '1'); - }, 10); -} - -function hideApply() { - var applyButton = $('#applyButton'); - - applyButton.css('opacity', '0'); - applyButton.removeClass('progress'); - - setTimeout(function () { - applyButton.css('display', 'none'); - }, 500); -} - -function isApplyVisible() { - var applyButton = $('#applyButton'); - - return applyButton.is(':visible'); -} - -function doApply() { - if (!configUiValid()) { - runAlertDialog('Make sure all the configuration options are valid!'); - return; - } - - function actualApply() { - /* gather the affected motion instances */ - var affectedInstances = {}; - Object.keys(pushConfigs).forEach(function (key) { - var config = pushConfigs[key]; - if (key === 'main') { - return; - } - - var instance; - if (config.proto == 'netcam' || config.proto == 'v4l2') { - instance = ''; - } - else if (config.proto == 'motioneye') { /* motioneye */ - instance = config.host || ''; - if (config.port) { - instance += ':' + config.port; - } - } - - affectedInstances[instance] = true; - }); - affectedInstances = Object.keys(affectedInstances); - - /* compute the affected camera ids */ - var cameraIdsByInstance = getCameraIdsByInstance(); - var affectedCameraIds = []; - - affectedInstances.forEach(function (instance) { - affectedCameraIds = affectedCameraIds.concat(cameraIdsByInstance[instance] || []); - }); - - beginProgress(affectedCameraIds); - affectedCameraIds.forEach(function (cameraId) { - refreshDisabled[cameraId] |= 0; - refreshDisabled[cameraId]++; - }); - - ajax('POST', baseUri + 'config/0/set/', pushConfigs, function (data) { - affectedCameraIds.forEach(function (cameraId) { - refreshDisabled[cameraId]--; - }); - - if (data == null || data.error) { - endProgress(); - showErrorMessage(data && data.error); - return; - } - - if (data.reboot) { - var count = 0; - function checkServerReboot() { - ajax('GET', baseUri + 'config/0/get/', null, - function () { - window.location.reload(true); - }, - function () { - if (count < 25) { - count += 1; - setTimeout(checkServerReboot, 2000); - } - else { - window.location.reload(true); - } - } - ); - } - - setTimeout(checkServerReboot, 15000); - - return; - } - - if (data.reload) { - window.location.reload(true); - return; - } - - /* update the camera name in the device select - * and frame title bar */ - Object.keys(pushConfigs).forEach(function (key) { - var config = pushConfigs[key]; - if (config.key !== 'main') { - $('#cameraSelect').find('option[value=' + key + ']').html(config.name); - } - - $('#camera' + key).find('span.camera-name').html(config.name); - }); - - pushConfigs = {}; - pushConfigReboot = false; - endProgress(); - recreateCameraFrames(); /* a camera could have been disabled */ - }); - } - - if (pushConfigReboot) { - runConfirmDialog('This will reboot the system. Continue?', function () { - actualApply(); - }); - } - else { - actualApply(); - } -} - -function doShutDown() { - runConfirmDialog('Really shut down?', function () { - ajax('POST', baseUri + 'power/shutdown/'); - setTimeout(function () { - refreshInterval = 1000000; - showModalDialog(''); - - function checkServer() { - ajax('GET', baseUri, null, - function () { - setTimeout(checkServer, 1000); - }, - function () { - showModalDialog('Powered Off'); - setTimeout(function () { - $('div.modal-glass').animate({'opacity': '1', 'background-color': '#212121'}, 200); - },100); - }, - 10000 /* timeout = 10s */ - ); - } - - checkServer(); - }, 10); - }); -} - -function doReboot() { - runConfirmDialog('Really reboot?', function () { - ajax('POST', baseUri + 'power/reboot/'); - setTimeout(function () { - refreshInterval = 1000000; - showModalDialog(''); - var shutDown = false; - - function checkServer() { - ajax('GET', baseUri, null, - function () { - if (!shutDown) { - setTimeout(checkServer, 1000); - } - else { - runAlertDialog('The system has been rebooted!', function () { - window.location.reload(true); - }); - } - }, - function () { - shutDown = true; /* the first error indicates the system was shut down */ - setTimeout(checkServer, 1000); - }, - 5 * 1000 /* timeout = 5s */ - ); - } - - checkServer(); - }, 10); - }); -} - -function doRemCamera() { - if (Object.keys(pushConfigs).length) { - return runAlertDialog('Please apply the modified settings first!'); - } - - var cameraId = $('#cameraSelect').val(); - if (cameraId == null || cameraId === 'add') { - runAlertDialog('No camera to remove!'); - return; - } - - var deviceName = $('#cameraSelect').find('option[value=' + cameraId + ']').text(); - - runConfirmDialog('Remove camera ' + deviceName + '?', function () { - /* disable further refreshing of this camera */ - var img = $('div.camera-frame#camera' + cameraId).find('img.camera'); - if (img.length) { - img[0].loading = 1; - } - - beginProgress(); - ajax('POST', baseUri + 'config/' + cameraId + '/rem/', null, function (data) { - if (data == null || data.error) { - endProgress(); - showErrorMessage(data && data.error); - return; - } - - fetchCurrentConfig(endProgress); - }); - }); -} - -function doUpdate() { - if (Object.keys(pushConfigs).length) { - return runAlertDialog('Please apply the modified settings first!'); - } - - showModalDialog(''); - ajax('GET', baseUri + 'update/', null, function (data) { - if (data.update_version == null) { - runAlertDialog('motionEye is up to date (current version: ' + data.current_version + ')'); - } - else { - runConfirmDialog('New version available: ' + data.update_version + '. Update?', function () { - refreshInterval = 1000000; - showModalDialog('
Updating. This may take a few minutes.
'); - ajax('POST', baseUri + 'update/?version=' + data.update_version, null, function () { - var count = 0; - function checkServer() { - ajax('GET', baseUri + 'config/0/get/', null, - function () { - runAlertDialog('motionEye was successfully updated!', function () { - window.location.reload(true); - }); - }, - function () { - if (count < 60) { - count += 1; - setTimeout(checkServer, 5000); - } - else { - runAlertDialog('Update failed!', function () { - window.location.reload(true); - }); - } - } - ); - } - - setTimeout(checkServer, 10000); - - }, function (e) { /* error */ - runAlertDialog('The update process has failed!', function () { - window.location.reload(true); - }); - }); - - return false; /* prevents hiding the modal container */ - }); - } - }); -} - -function doBackup() { - downloadFile('config/backup/'); -} - -function doRestore() { - var content = - $('
' + - '' + - '' + - '' + - '' + - '' + - '
Backup File
?
'); - - /* collect ui widgets */ - var fileInput = content.find('#fileInput'); - - /* make validators */ - makeFileValidator(fileInput, true); - - function uiValid() { - /* re-validate all the validators */ - content.find('.validator').each(function () { - this.validate(); - }); - - var valid = true; - var query = content.find('input, select'); - query.each(function () { - if (this.invalid) { - valid = false; - return false; - } - }); - - return valid; - } - - runModalDialog({ - title: 'Restore Configuration', - closeButton: true, - buttons: 'okcancel', - content: content, - onOk: function () { - if (!uiValid(true)) { - return false; - } - - refreshInterval = 1000000; - - setTimeout(function () { - showModalDialog('
Restoring configuration...
'); - uploadFile(baseUri + 'config/restore/', fileInput, function (data) { - if (data && data.ok) { - var count = 0; - function checkServer() { - ajax('GET', baseUri + 'config/0/get/', null, - function () { - runAlertDialog('The configuration has been restored!', function () { - window.location.reload(true); - }); - }, - function () { - if (count < 25) { - count += 1; - setTimeout(checkServer, 2000); - } - else { - runAlertDialog('Failed to restore the configuration!', function () { - window.location.reload(true); - }); - } - } - ); - } - - if (data.reboot) { - setTimeout(checkServer, 10000); - } - else { - setTimeout(function () { - window.location.reload(); - }, 5000); - } - } - else { - hideModalDialog(); - showErrorMessage('Failed to restore the configuration!'); - } - }); - }, 10); - } - }); -} - -function doDownloadZipped(cameraId, groupKey) { - showModalDialog('', null, null, true); - ajax('GET', baseUri + 'picture/' + cameraId + '/zipped/' + groupKey + '/', null, function (data) { - if (data.error) { - hideModalDialog(); /* progress */ - showErrorMessage(data.error); - } - else { - hideModalDialog(); /* progress */ - downloadFile('picture/' + cameraId + '/zipped/' + groupKey + '/?key=' + data.key); - } - }); -} - -function doDeleteFile(uri, callback) { - var url = window.location.href; - var parts = url.split('/'); - url = parts.slice(0, 3).join('/') + uri; - - runConfirmDialog('Really delete this file?', function () { - showModalDialog('', null, null, true); - ajax('POST', url, null, function (data) { - hideModalDialog(); /* progress */ - hideModalDialog(); /* confirm */ - - if (data == null || data.error) { - showErrorMessage(data && data.error); - return; - } - - if (callback) { - callback(); - } - }); - - return false; - }, {stack: true}); -} - -function doDeleteAllFiles(mediaType, cameraId, groupKey, callback) { - runConfirmDialog('Really delete all ' + mediaType + 's in ' + groupKey + '?', function () { - showModalDialog('', null, null, true); - ajax('POST', baseUri + mediaType + '/' + cameraId + '/delete_all/' + groupKey + '/', null, function (data) { - hideModalDialog(); /* progress */ - hideModalDialog(); /* confirm */ - - if (data == null || data.error) { - showErrorMessage(data && data.error); - return; - } - - if (callback) { - callback(); - } - }); - - return false; - }, {stack: true}); -} - - - /* fetch & push */ - -function fetchCurrentConfig(onFetch) { - function fetchCameraList() { - /* fetch the camera list */ - ajax('GET', baseUri + 'config/list/', null, function (data) { - if (data == null || data.error) { - showErrorMessage(data && data.error); - data = {cameras: []}; - if (onFetch) { - onFetch(null); - } - } - - var i, cameras = data.cameras; - - if (isAdmin()) { - var cameraSelect = $('#cameraSelect'); - cameraSelect.html(''); - for (i = 0; i < cameras.length; i++) { - var camera = cameras[i]; - cameraSelect.append(''); - } - cameraSelect.append(''); - - var enabledCameras = cameras.filter(function (camera) {return camera['enabled'];}); - if (enabledCameras.length > 0) { /* prefer the first enabled camera */ - cameraSelect[0].selectedIndex = cameras.indexOf(enabledCameras[0]); - fetchCurrentCameraConfig(onFetch); - } - else if (cameras.length) { /* only disabled cameras */ - cameraSelect[0].selectedIndex = 0; - fetchCurrentCameraConfig(onFetch); - } - else { /* no camera at all */ - cameraSelect[0].selectedIndex = -1; - - if (onFetch) { - onFetch(data); - } - } - - updateConfigUi(); - } - else { /* normal user */ - if (!cameras.length) { - /* normal user with no cameras doesn't make too much sense - force login */ - doLogout(); - } - - if (onFetch) { - onFetch(data); - } - } - - var mainLoadingProgressImg = $('img.main-loading-progress'); - if (mainLoadingProgressImg.length) { - mainLoadingProgressImg.animate({'opacity': 0}, 200, function () { - recreateCameraFrames(cameras); - mainLoadingProgressImg.remove(); - }); - } - else { - recreateCameraFrames(cameras); - } - }); - } - - /* add a progress indicator */ - $('div.page-container').append(''); - - if (isAdmin()) { - /* fetch the main configuration */ - ajax('GET', baseUri + 'config/main/get/', null, function (data) { - if (data == null || data.error) { - showErrorMessage(data && data.error); - return; - } - - dict2MainUi(data); - fetchCameraList(); - }); - } - else { - fetchCameraList(); - } -} - -function fetchCurrentCameraConfig(onFetch) { - var cameraId = $('#cameraSelect').val(); - if (cameraId != null) { - ajax('GET', baseUri + 'config/' + cameraId + '/get/?force=true', null, function (data) { - if (data == null || data.error) { - showErrorMessage(data && data.error); - dict2CameraUi(null); - if (onFetch) { - onFetch(null); - } - - return; - } - - dict2CameraUi(data); - if (onFetch) { - onFetch(data); - } - }); - } - else { - dict2CameraUi({}); - if (onFetch) { - onFetch({}); - } - } -} - -function pushMainConfig(reboot) { - var mainConfig = mainUi2Dict(); - - pushConfigReboot = pushConfigReboot || reboot; - pushConfigs['main'] = mainConfig; - if (!isApplyVisible()) { - showApply(); - } -} - -function pushCameraConfig(reboot) { - var cameraConfig = cameraUi2Dict(); - var cameraId = $('#cameraSelect').val(); - - if (!cameraId) { - return; /* event triggered without a selected camera */ - } - - pushConfigReboot = pushConfigReboot || reboot; - pushConfigs[cameraId] = cameraConfig; - if (!isApplyVisible()) { - showApply(); - } - - /* also update the config stored in the camera frame div */ - var cameraFrame = $('div.camera-frame#camera' + cameraId); - if (cameraFrame.length) { - Object.update(cameraFrame[0].config, cameraConfig); - } -} - -function pushPreview(control) { - var cameraId = $('#cameraSelect').val(); - - var brightness = $('#brightnessSlider').val(); - var contrast= $('#contrastSlider').val(); - var saturation = $('#saturationSlider').val(); - var hue = $('#hueSlider').val(); - - var data = {}; - - if (brightness !== '' && (!control || control == 'brightness')) { - data.brightness = brightness; - } - - if (contrast !== '' && (!control || control == 'contrast')) { - data.contrast = contrast; - } - - if (saturation !== '' && (!control || control == 'saturation')) { - data.saturation = saturation; - } - - if (hue !== '' && (!control || control == 'hue')) { - data.hue = hue; - } - - refreshDisabled[cameraId] |= 0; - refreshDisabled[cameraId]++; - - ajax('POST', baseUri + 'config/' + cameraId + '/set_preview/', data, function (data) { - refreshDisabled[cameraId]--; - - if (data == null || data.error) { - showErrorMessage(data && data.error); - return; - } - }); -} - -function getCameraIdsByInstance() { - /* a motion instance is identified by the (host, port) pair; - * the local instance has both the host and the port set to empty string */ - - var cameraIdsByInstance = {}; - $('div.camera-frame').each(function () { - var instance; - if (this.config.proto == 'netcam' || this.config.proto == 'v4l2') { - instance = ''; - } - else if (this.config.proto == 'motioneye') { - instance = this.config.host || ''; - if (this.config.port) { - instance += ':' + this.config.port; - } - } - else { /* assuming simple mjpeg camera */ - return; - } - - (cameraIdsByInstance[instance] = cameraIdsByInstance[instance] || []).push(this.config.id); - }); - - return cameraIdsByInstance; -} - - - /* dialogs */ - -function runAlertDialog(message, onOk, options) { - var params = { - title: message, - buttons: 'ok', - onOk: onOk - }; - - if (options) { - Object.update(params, options); - } - - runModalDialog(params); -} - -function runConfirmDialog(message, onYes, options) { - var params = { - title: message, - buttons: 'yesno', - onYes: onYes - }; - - if (options) { - Object.update(params, options); - } - - runModalDialog(params); -} - -function runLoginDialog(retry) { - /* a workaround so that browsers will remember the credentials */ - var tempFrame = $(''); - $('body').append(tempFrame); - - var form = - $('
' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
'); - - var usernameEntry = form.find('#usernameEntry'); - var passwordEntry = form.find('#passwordEntry'); - var errorTd = form.find('td.login-dialog-error'); - - if (window._loginRetry) { - errorTd.css('display', 'table-cell'); - errorTd.html('Invalid credentials.'); - } - - var params = { - title: 'Login', - content: form, - buttons: [ - {caption: 'Cancel', isCancel: true, click: function () { - tempFrame.remove(); - }}, - {caption: 'Login', isDefault: true, click: function () { - window.username = usernameEntry.val(); - window.password = passwordEntry.val(); - window._loginDialogSubmitted = true; - - setCookie('username', window.username); - - form.submit(); - setTimeout(function () { - tempFrame.remove(); - }, 5000); - - if (retry) { - retry(); - } - }} - ], - }; - - runModalDialog(params); -} - -function runPictureDialog(entries, pos, mediaType) { - var content = $('
'); - - var img = $(''); - content.append(img); - - var prevArrow = $('
'); - content.append(prevArrow); - - var nextArrow = $('
'); - content.append(nextArrow); - - var progressImg = $(''); - - function updatePicture() { - var entry = entries[pos]; - - var windowWidth = $(window).width(); - var windowHeight = $(window).height(); - var widthCoef = windowWidth < 1000 ? 0.8 : 0.5; - var heightCoef = 0.75; - - var width = parseInt(windowWidth * widthCoef); - var height = parseInt(windowHeight * heightCoef); - - prevArrow.css('display', 'none'); - nextArrow.css('display', 'none'); - img.parent().append(progressImg); - updateModalDialogPosition(); - progressImg.css('left', (img.parent().width() - progressImg.width()) / 2); - progressImg.css('top', (img.parent().height() - progressImg.height()) / 2); - - img.attr('src', addAuthParams('GET', baseUri + mediaType + '/' + entry.cameraId + '/preview' + entry.path)); - img.load(function () { - var aspectRatio = this.naturalWidth / this.naturalHeight; - var sizeWidth = width * width / aspectRatio; - var sizeHeight = height * aspectRatio * height; - - if (sizeWidth < sizeHeight) { - img.width(width); - } - else { - img.height(height); - } - updateModalDialogPosition(); - prevArrow.css('display', pos > 0 ? '' : 'none'); - nextArrow.css('display', pos < entries.length - 1 ? '' : 'none'); - progressImg.remove(); - }); - - $('div.modal-container').find('span.modal-title:last').html(entry.name); - updateModalDialogPosition(); - } - - prevArrow.click(function () { - if (pos > 0) { - pos--; - } - - updatePicture(); - }); - - nextArrow.click(function () { - if (pos < entries.length - 1) { - pos++; - } - - updatePicture(); - }); - - function bodyKeyDown(e) { - switch (e.which) { - case 37: - if (prevArrow.is(':visible')) { - prevArrow.click(); - } - break; - - case 39: - if (nextArrow.is(':visible')) { - nextArrow.click(); - } - break; - } - } - - $('body').on('keydown', bodyKeyDown); - - img.load(updateModalDialogPosition); - - runModalDialog({ - title: ' ', - closeButton: true, - buttons: [ - {caption: 'Close'}, - {caption: 'Download', isDefault: true, click: function () { - var entry = entries[pos]; - downloadFile(mediaType + '/' + entry.cameraId + '/download' + entry.path); - - return false; - }} - ], - content: content, - stack: true, - onShow: updatePicture, - onClose: function () { - $('body').off('keydown', bodyKeyDown); - } - }); -} - -function runAddCameraDialog() { - if (!$('#motionEyeSwitch')[0].checked) { - return runAlertDialog('Please enable motionEye first!'); - } - - if (Object.keys(pushConfigs).length) { - return runAlertDialog('Please apply the modified settings first!'); - } - - var content = - $('' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
Camera Type?
URL?
Username?
Password?
Camera?
'); - - /* collect ui widgets */ - var typeSelect = content.find('#typeSelect'); - var urlEntry = content.find('#urlEntry'); - var usernameEntry = content.find('#usernameEntry'); - var passwordEntry = content.find('#passwordEntry'); - var addCameraSelect = content.find('#addCameraSelect'); - var addCameraInfo = content.find('#addCameraInfo'); - var cameraMsgLabel = content.find('#cameraMsgLabel'); - - /* make validators */ - makeUrlValidator(urlEntry, true); - makeTextValidator(usernameEntry, false); - makeTextValidator(typeSelect, false); - makeComboValidator(addCameraSelect, true); - - /* ui interaction */ - function updateUi() { - content.find('tr.v4l2, tr.motioneye, tr.netcam, tr.mjpeg').css('display', 'none'); - - if (typeSelect.val() == 'motioneye') { - content.find('tr.motioneye').css('display', 'table-row'); - usernameEntry.val('admin'); - usernameEntry.attr('readonly', 'readonly'); - addCameraInfo.html( - 'Remote motionEye cameras are cameras installed behind another motionEye server. ' + - 'Adding them here will allow you to view and manage them remotely.'); - } - else if (typeSelect.val() == 'netcam') { - usernameEntry.removeAttr('readonly'); - - /* make sure there is one trailing slash so that - * an URI can be detected */ - var url = urlEntry.val().trim(); - var m = url.match(new RegExp('/', 'g')); - if (m && m.length < 3 && !url.endsWith('/')) { - urlEntry.val(url + '/'); - } - - content.find('tr.netcam').css('display', 'table-row'); - addCameraInfo.html( - 'Network cameras (or IP cameras) are devices that natively stream RTSP or MJPEG videos or plain JPEG images. ' + - "Consult your device's manual to find out the correct RTSP, MJPEG or JPEG URL."); - } - else if (typeSelect.val() == 'mjpeg') { - usernameEntry.removeAttr('readonly'); - - /* make sure there is one trailing slash so that - * an URI can be detected */ - var url = urlEntry.val().trim(); - var m = url.match(new RegExp('/', 'g')); - if (m && m.length < 3 && !url.endsWith('/')) { - urlEntry.val(url + '/'); - } - - content.find('tr.mjpeg').css('display', 'table-row'); - addCameraInfo.html( - 'Adding your device as a simple MJPEG camera instead of as a network camera will improve the framerate, ' + - 'but no motion detection, picture capturing or movie recording will be available for it. ' + - 'The camera must be accessible to both your server and your browser. ' + - 'This type of camera is not compatible with Internet Explorer.'); - } - else { /* assuming v4l2 */ - content.find('tr.v4l2').css('display', 'table-row'); - addCameraInfo.html( - 'Local cameras are camera devices that are connected directly to your motionEye system. ' + - 'These are usually USB webcams or board-specific cameras.'); - } - - updateModalDialogPosition(); - - /* re-validate all the validators */ - content.find('.validator').each(function () { - this.validate(); - }); - - if (uiValid()) { - listCameras(); - } - } - - function uiValid(includeCameraSelect) { - var query = content.find('input, select'); - if (!includeCameraSelect) { - query = query.not('#addCameraSelect'); - } - else { - if (cameraMsgLabel.html() || !addCameraSelect.val()) { - return false; - } - } - - /* re-validate all the validators */ - content.find('.validator').each(function () { - this.validate(); - }); - - var valid = true; - query.each(function () { - if (this.invalid) { - valid = false; - return false; - } - }); - - return valid; - } - - function splitCameraUrl(url) { - var parts = url.split('://'); - var scheme = parts[0]; - var index = parts[1].indexOf('/'); - var host = null; - var uri = ''; - if (index >= 0) { - host = parts[1].substring(0, index); - uri = parts[1].substring(index); - } - else { - host = parts[1]; - } - - var port = ''; - parts = host.split(':'); - if (parts.length >= 2) { - host = parts[0]; - port = parts[1]; - } - - if (uri == '') { - uri = '/'; - } - - return { - scheme: scheme, - host: host, - port: port, - uri: uri - }; - } - - function listCameras() { - var progress = $('
'); - - addCameraSelect.html(''); - addCameraSelect.hide(); - addCameraSelect.parent().find('div').remove(); /* remove any previous progress div */ - addCameraSelect.before(progress); - - var data = {}; - if (urlEntry.is(':visible') && urlEntry.val()) { - data = splitCameraUrl(urlEntry.val()); - } - data.username = usernameEntry.val(); - data.password = passwordEntry.val(); - data.proto = typeSelect.val(); - - cameraMsgLabel.html(''); - - ajax('GET', baseUri + 'config/list/', data, function (data) { - progress.remove(); - - if (data == null || data.error) { - cameraMsgLabel.html(data && data.error); - - return; - } - - if (data.error || !data.cameras) { - return; - } - - data.cameras.forEach(function (info) { - var option = $(''); - option[0]._extra_attrs = {}; - Object.keys(info).forEach(function (key) { - if (key == 'id' || key == 'name') { - return; - } - - var value = info[key]; - option[0]._extra_attrs[key] = value; - }); - - addCameraSelect.append(option); - }); - - if (!data.cameras || !data.cameras.length) { - addCameraSelect.append(''); - } - - addCameraSelect.show(); - addCameraSelect[0].validate(); - }); - } - - typeSelect.change(function () { - addCameraSelect.html(''); - }); - - typeSelect.change(updateUi); - urlEntry.change(updateUi); - usernameEntry.change(updateUi); - passwordEntry.change(updateUi); - updateUi(); - - runModalDialog({ - title: 'Add Camera...', - closeButton: true, - buttons: 'okcancel', - content: content, - onOk: function () { - if (!uiValid(true)) { - return false; - } - - var data = {}; - - if (typeSelect.val() == 'motioneye') { - data = splitCameraUrl(urlEntry.val()); - data.proto = 'motioneye'; - data.username = usernameEntry.val(); - data.password = passwordEntry.val(); - data.remote_camera_id = addCameraSelect.val(); - } - else if (typeSelect.val() == 'netcam') { - data = splitCameraUrl(urlEntry.val()); - 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()); - data.username = usernameEntry.val(); - data.password = passwordEntry.val(); - data.proto = 'mjpeg'; - } - else { /* assuming v4l2 */ - data.proto = 'v4l2'; - data.uri = addCameraSelect.val(); - } - - /* add all extra attributes */ - var option = addCameraSelect.find('option:eq(' + addCameraSelect[0].selectedIndex + ')')[0]; - Object.keys(option._extra_attrs).forEach(function (key) { - var value = option._extra_attrs[key]; - data[key] = value; - }); - - beginProgress(); - ajax('POST', baseUri + 'config/add/', data, function (data) { - endProgress(); - - if (data == null || data.error) { - showErrorMessage(data && data.error); - return; - } - - var cameraOption = $('#cameraSelect').find('option[value=add]'); - cameraOption.before(''); - $('#cameraSelect').val(data.id).change(); - recreateCameraFrames(); - }); - } - }); -} - -function runTimelapseDialog(cameraId, groupKey, group) { - var content = - $('' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
Group' + groupKey + '
Include a picture taken every' + - '' + - '?
Movie framerate?
'); - - var intervalSelect = content.find('#intervalSelect'); - var framerateSlider = content.find('#framerateSlider'); - var timelapseWarning = content.find('td.timelapse-warning'); - - if (group.length > 1440) { /* one day worth of pictures, taken 1 minute apart */ - timelapseWarning.html('Given the large number of pictures, creating your timelapse might take a while!'); - timelapseWarning.css('display', 'table-cell'); - } - - makeSlider(framerateSlider, 1, 100, 0, [ - {value: 1, label: '1'}, - {value: 20, label: '20'}, - {value: 40, label: '40'}, - {value: 60, label: '60'}, - {value: 80, label: '80'}, - {value: 100, label: '100'} - ], null, 0); - - intervalSelect.val(60); - framerateSlider.val(20).each(function () {this.update()}); - - runModalDialog({ - title: 'Create Timelapse Movie', - closeButton: true, - buttons: 'okcancel', - content: content, - onOk: function () { - var progressBar = $('
'); - makeProgressBar(progressBar); - - runModalDialog({ - title: 'Creating Timelapse Movie...', - content: progressBar, - stack: true, - noKeys: true - }); - - var url = baseUri + 'picture/' + cameraId + '/timelapse/' + groupKey + '/'; - var data = {interval: intervalSelect.val(), framerate: framerateSlider.val()}; - var first = true; - - function checkTimelapse() { - var actualUrl = url; - if (!first) { - actualUrl += '?check=true'; - } - - ajax('GET', actualUrl, data, function (data) { - if (data == null || data.error) { - hideModalDialog(); /* progress */ - hideModalDialog(); /* timelapse dialog */ - showErrorMessage(data && data.error); - return; - } - - if (data.progress != -1 && first) { - showPopupMessage('A timelapse movie is already being created.'); - } - - if (data.progress == -1 && !first && !data.key) { - hideModalDialog(); /* progress */ - hideModalDialog(); /* timelapse dialog */ - showErrorMessage('The timelapse movie could not be created.'); - return; - } - - if (data.progress == -1) { - data.progress = 0; - } - - if (data.key) { - progressBar[0].setProgress(100); - progressBar[0].setText('100%'); - - setTimeout(function () { - hideModalDialog(); /* progress */ - hideModalDialog(); /* timelapse dialog */ - downloadFile('picture/' + cameraId + '/timelapse/' + groupKey + '/?key=' + data.key); - }, 500); - } - else { - progressBar[0].setProgress(data.progress * 100); - progressBar[0].setText(parseInt(data.progress * 100) + '%'); - setTimeout(checkTimelapse, 1000); - } - - first = false; - }); - } - - checkTimelapse(); - - return false; - }, - stack: true - }); -} - -function runMediaDialog(cameraId, mediaType) { - var dialogDiv = $('
'); - var mediaListDiv = $('
'); - var groupsDiv = $('
'); - var buttonsDiv = $('
'); - - var groups = {}; - var groupKey = null; - - dialogDiv.append(groupsDiv); - dialogDiv.append(mediaListDiv); - dialogDiv.append(buttonsDiv); - - /* add a temporary div to compute 3em in px */ - var tempDiv = $('
'); - $('div.modal-container').append(tempDiv); - var height = tempDiv.height(); - tempDiv.remove(); - - function showGroup(key) { - groupKey = key; - - if (mediaListDiv.find('img.media-list-progress').length) { - return; /* already in progress of loading */ - } - - /* (re)set the current state of the group buttons */ - groupsDiv.find('div.media-dialog-group-button').each(function () { - var $this = $(this); - if (this.key == key) { - $this.addClass('current'); - } - else { - $this.removeClass('current'); - } - }); - - var mediaListByName = {}; - var entries = groups[key]; - - /* cleanup the media list */ - mediaListDiv.children('div.media-list-entry').detach(); - mediaListDiv.html(''); - - function addEntries() { - /* add the entries to the media list */ - entries.forEach(function (entry, i) { - var entryDiv = entry.div; - var detailsDiv = null; - - if (!entryDiv) { - entryDiv = $('
'); - - var previewImg = $(''); - entryDiv.append(previewImg); - previewImg[0]._src = addAuthParams('GET', baseUri + mediaType + '/' + cameraId + '/preview' + entry.path + '?height=' + height); - - var downloadButton = $('
Download
'); - entryDiv.append(downloadButton); - - var deleteButton = $('
Delete
'); - if (isAdmin()) { - entryDiv.append(deleteButton); - } - - var nameDiv = $('
' + entry.name + '
'); - entryDiv.append(nameDiv); - - detailsDiv = $('
'); - entryDiv.append(detailsDiv); - - downloadButton.click(function () { - downloadFile(mediaType + '/' + cameraId + '/download' + entry.path); - return false; - }); - - deleteButton.click(function () { - doDeleteFile(baseUri + mediaType + '/' + cameraId + '/delete' + entry.path, function () { - entryDiv.remove(); - entries.splice(i, 1); /* remove entry from group */ - - /* update text on group button */ - groupsDiv.find('div.media-dialog-group-button').each(function () { - var $this = $(this); - if (this.key == groupKey) { - var text = this.innerHTML; - text = text.substring(0, text.lastIndexOf(' ')); - text += ' (' + entries.length + ')'; - this.innerHTML = text; - } - }); - }); - - return false; - }); - - entryDiv.click(function () { - var pos = entries.indexOf(entry); - runPictureDialog(entries, pos, mediaType); - }); - - entry.div = entryDiv; - } - else { - detailsDiv = entry.div.find('div.media-list-entry-details'); - } - - var momentSpan = $('' + entry.momentStr + ', '); - var momentShortSpan = $('' + entry.momentStrShort + ''); - var sizeSpan = $('' + entry.sizeStr + ''); - detailsDiv.empty(); - detailsDiv.append(momentSpan); - detailsDiv.append(momentShortSpan); - detailsDiv.append(sizeSpan); - mediaListDiv.append(entryDiv); - }); - - /* trigger a scroll event */ - mediaListDiv.scroll(); - } - - /* if details are already fetched, simply add the entries and return */ - if (entries[0].timestamp) { - return addEntries(); - } - - var previewImg = $(''); - mediaListDiv.append(previewImg); - - var url = baseUri + mediaType + '/' + cameraId + '/list/?prefix=' + (key || 'ungrouped'); - ajax('GET', url, null, function (data) { - previewImg.remove(); - - if (data == null || data.error) { - hideModalDialog(); - showErrorMessage(data && data.error); - return; - } - - /* index the media list by name */ - data.mediaList.forEach(function (media) { - var path = media.path; - var parts = path.split('/'); - var name = parts[parts.length - 1]; - - mediaListByName[name] = media; - }); - - /* assign details to entries */ - entries.forEach(function (entry) { - var media = mediaListByName[entry.name]; - if (media) { - entry.momentStr = media.momentStr; - entry.momentStrShort = media.momentStrShort; - entry.sizeStr = media.sizeStr; - entry.timestamp = media.timestamp; - } - }); - - /* sort the entries by timestamp */ - entries.sortKey(function (e) {return e.timestamp || e.name;}, true); - - addEntries(); - }); - } - - if (mediaType == 'picture') { - var zippedButton = $('
Zipped
'); - buttonsDiv.append(zippedButton); - - zippedButton.click(function () { - if (groupKey != null) { - doDownloadZipped(cameraId, groupKey); - } - }); - - var timelapseButton = $('
Timelapse
'); - buttonsDiv.append(timelapseButton); - - timelapseButton.click(function () { - if (groupKey != null) { - runTimelapseDialog(cameraId, groupKey, groups[groupKey]); - } - }); - } - - if (isAdmin()) { - var deleteAllButton = $('
Delete All
'); - buttonsDiv.append(deleteAllButton); - - deleteAllButton.click(function () { - if (groupKey != null) { - doDeleteAllFiles(mediaType, cameraId, groupKey, function () { - /* delete th group button */ - groupsDiv.find('div.media-dialog-group-button').each(function () { - var $this = $(this); - if (this.key == groupKey) { - $this.remove(); - } - }); - - /* delete the group itself */ - delete groups[groupKey]; - - /* show the first existing group, if any */ - var keys = Object.keys(groups); - if (keys.length) { - showGroup(keys[0]); - } - else { - hideModalDialog(); - } - }); - } - }); - } - - function updateDialogSize() { - var windowWidth = $(window).width(); - var windowHeight = $(window).height(); - - if (Object.keys(groups).length == 0) { - groupsDiv.width('auto'); - groupsDiv.height('auto'); - groupsDiv.addClass('small-screen'); - mediaListDiv.width('auto'); - mediaListDiv.height('auto'); - buttonsDiv.hide(); - - return; - } - - buttonsDiv.show(); - - if (windowWidth < 1000) { - mediaListDiv.width(parseInt(windowWidth * 0.8)); - mediaListDiv.height(parseInt(windowHeight * 0.7)); - groupsDiv.width(parseInt(windowWidth * 0.8)); - groupsDiv.height(''); - groupsDiv.addClass('small-screen'); - } - else { - mediaListDiv.width(parseInt(windowWidth * 0.7)); - mediaListDiv.height(parseInt(windowHeight * 0.7)); - groupsDiv.height(parseInt(windowHeight * 0.7)); - groupsDiv.width(''); - groupsDiv.removeClass('small-screen'); - } - } - - function onResize() { - updateDialogSize(); - updateModalDialogPosition(); - } - - $(window).resize(onResize); - - updateDialogSize(); - - showModalDialog(''); - - /* fetch the media list */ - ajax('GET', baseUri + mediaType + '/' + cameraId + '/list/', null, function (data) { - if (data == null || data.error) { - hideModalDialog(); - showErrorMessage(data && data.error); - return; - } - - /* group the media */ - data.mediaList.forEach(function (media) { - var path = media.path; - var parts = path.split('/'); - var keyParts = parts.splice(0, parts.length - 1); - var key = keyParts.join('/'); - - if (key.indexOf('/') === 0) { - key = key.substring(1); - } - - var list = (groups[key] = groups[key] || []); - - list.push({ - 'path': path, - 'group': key, - 'name': parts[parts.length - 1], - 'cameraId': cameraId - }); - }); - - updateDialogSize(); - - var keys = Object.keys(groups); - keys.sort(); - keys.reverse(); - - if (keys.length) { - keys.forEach(function (key) { - var groupButton = $('
'); - groupButton.text((key || '(ungrouped)') + ' (' + groups[key].length + ')'); - groupButton[0].key = key; - - groupButton.click(function () { - showGroup(key); - }); - - groupsDiv.append(groupButton); - }); - - /* add tooltips to larger group buttons */ - setTimeout(function () { - groupsDiv.find('div.media-dialog-group-button').each(function () { - if (this.scrollWidth > this.offsetWidth) { - this.title = this.innerHTML; - } - }); - }, 10); - } - else { - groupsDiv.html('(no media files)'); - mediaListDiv.remove(); - } - - var title; - if ($(window).width() < 1000) { - title = data.cameraName; - } - else if (mediaType === 'picture') { - title = 'Pictures taken by ' + data.cameraName; - } - else { - title = 'Movies recorded by ' + data.cameraName; - } - - runModalDialog({ - title: title, - closeButton: true, - buttons: '', - content: dialogDiv, - onShow: function () { - //dialogDiv.scrollTop(dialogDiv.prop('scrollHeight')); - if (keys.length) { - showGroup(keys[0]); - } - }, - onClose: function () { - $(window).unbind('resize', onResize); - } - }); - }); - - /* install the media list scroll event handler */ - mediaListDiv.scroll(function () { - var height = mediaListDiv.height(); - - mediaListDiv.find('img.media-list-preview').each(function () { - if (!this._src) { - return; - } - - var $this = $(this); - var entryDiv = $this.parent(); - - var top1 = entryDiv.position().top; - var top2 = top1 + entryDiv.height(); - - if ((top1 >= 0 && top1 <= height) || - (top2 >= 0 && top2 <= height)) { - - this.src = this._src; - delete this._src; - } - }); - }); -} - - - /* camera frames */ - -function addCameraFrameUi(cameraConfig) { - var pageContainer = $('div.page-container'); - - if (cameraConfig == null) { - var cameraFrameDivPlaceHolder = $('
'); - pageContainer.append(cameraFrameDivPlaceHolder); - - return; - } - - var cameraId = cameraConfig.id; - - var cameraFrameDiv = $( - '
' + - '
' + - '' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '
' + - '
' + - '
'); - - var nameSpan = cameraFrameDiv.find('span.camera-name'); - var configureButton = cameraFrameDiv.find('div.camera-button.configure'); - var picturesButton = cameraFrameDiv.find('div.camera-button.media-pictures'); - var moviesButton = cameraFrameDiv.find('div.camera-button.media-movies'); - var fullScreenButton = cameraFrameDiv.find('div.camera-button.full-screen'); - var cameraPlaceholder = cameraFrameDiv.find('div.camera-placeholder'); - var cameraProgress = cameraFrameDiv.find('div.camera-progress'); - var cameraImg = cameraFrameDiv.find('img.camera'); - var progressImg = cameraFrameDiv.find('img.camera-progress'); - - /* no camera buttons if not admin */ - if (!isAdmin()) { - configureButton.hide(); - } - - /* no media buttons for simple mjpeg cameras */ - if (cameraConfig['proto'] == 'mjpeg') { - picturesButton.hide(); - moviesButton.hide(); - } - - cameraFrameDiv.attr('id', 'camera' + cameraId); - cameraFrameDiv[0].refreshDivider = 0; - cameraFrameDiv[0].config = cameraConfig; - nameSpan.html(cameraConfig.name); - progressImg.attr('src', staticUrl + 'img/camera-progress.gif'); - - cameraProgress.click(function () { - doFullScreenCamera(cameraId); - }); - - cameraProgress.addClass('visible'); - cameraPlaceholder.css('opacity', '0'); - - /* insert the new camera frame at the right position, - * with respect to the camera id */ - var cameraFrames = pageContainer.find('div.camera-frame'); - var cameraIds = cameraFrames.map(function () {return parseInt(this.id.substring(6));}); - cameraIds.sort(); - - var index = 0; /* find the first position that is greater than the current camera id */ - while (index < cameraIds.length && cameraIds[index] < cameraId) { - index++; - } - - if (index < cameraIds.length) { - var beforeCameraFrame = pageContainer.find('div.camera-frame#camera' + cameraIds[index]); - cameraFrameDiv.insertAfter(beforeCameraFrame); - } - else { - pageContainer.append(cameraFrameDiv); - } - - /* fade in */ - cameraFrameDiv.animate({'opacity': 1}, 100); - - /* add the button handlers */ - configureButton.click(function () { - doConfigureCamera(cameraId); - }); - - picturesButton.click(function (cameraId) { - return function () { - runMediaDialog(cameraId, 'picture'); - }; - }(cameraId)); - - moviesButton.click(function (cameraId) { - return function () { - runMediaDialog(cameraId, 'movie'); - }; - }(cameraId)); - - fullScreenButton.click(function (cameraId) { - return function () { - var url = baseUri + 'picture/' + cameraId + '/frame/'; - window.open(url, '_blank'); - }; - }(cameraId)); - - /* error and load handlers */ - cameraImg.error(function () { - this.error = true; - this.loading = 0; - - cameraImg.addClass('error').removeClass('loading'); - cameraImg.height(Math.round(cameraImg.width() * 0.75)); - cameraPlaceholder.css('opacity', 1); - cameraProgress.removeClass('visible'); - cameraFrameDiv.removeClass('motion-detected'); - }); - cameraImg.load(function () { - if (refreshDisabled[cameraId]) { - return; /* refresh temporarily disabled for updating */ - } - - this.error = false; - this.loading = 0; - - cameraImg.removeClass('error').removeClass('loading'); - cameraImg.css('height', ''); - cameraPlaceholder.css('opacity', 0); - cameraProgress.removeClass('visible'); - - /* there's no point in looking for a cookie update more often than once every second */ - var now = new Date().getTime(); - if ((!this.lastCookieTime || now - this.lastCookieTime > 1000) && (cameraFrameDiv[0].config['proto'] != 'mjpeg')) { - if (getCookie('motion_detected_' + cameraId) == 'true') { - cameraFrameDiv.addClass('motion-detected'); - } - else { - cameraFrameDiv.removeClass('motion-detected'); - } - - this.lastCookieTime = now; - } - - if (fullScreenCameraId) { - /* update the modal dialog position when image is loaded */ - updateModalDialogPosition(); - } - }); - - cameraImg.addClass('loading'); - cameraImg.height(Math.round(cameraImg.width() * 0.75)); -} - -function remCameraFrameUi(cameraId) { - var pageContainer = $('div.page-container'); - var cameraFrameDiv = pageContainer.find('div.camera-frame#camera' + cameraId); - cameraFrameDiv.animate({'opacity': 0}, 100, function () { - cameraFrameDiv.remove(); - }); -} - -function recreateCameraFrames(cameras) { - var pageContainer = $('div.page-container'); - - function updateCameras(cameras) { - cameras = cameras.filter(function (camera) {return camera.enabled;}); - var i, camera; - - /* remove everything on the page */ - pageContainer.children().remove(); - - /* add camera frames */ - for (i = 0; i < cameras.length; i++) { - camera = cameras[i]; - addCameraFrameUi(camera); - } - - if ($('#cameraSelect').find('option').length < 2 && isAdmin() && $('#motionEyeSwitch')[0].checked) { - /* invite the user to add a camera */ - var addCameraLink = $(''); - pageContainer.append(addCameraLink); - } - } - - if (cameras != null) { - updateCameras(cameras); - } - else { - ajax('GET', baseUri + 'config/list/', null, function (data) { - if (data == null || data.error) { - showErrorMessage(data && data.error); - return; - } - - updateCameras(data.cameras); - }); - } - - /* update settings panel */ - var cameraId = $('#cameraSelect').val(); - if (cameraId && cameraId != 'add') { - openSettings(cameraId); - } -} - - -function doConfigureCamera(cameraId) { - if (inProgress) { - return; - } - - hideApply(); - pushConfigs = {}; - pushConfigReboot = false; - - openSettings(cameraId); -} - -function doFullScreenCamera(cameraId) { - if (inProgress || refreshDisabled[cameraId]) { - return; - } - - if (fullScreenCameraId != null) { - return; /* a camera is already in full screen */ - } - - fullScreenCameraId = -1; /* avoids successive fast toggles of fullscreen */ - - var cameraFrameDiv = $('#camera' + cameraId); - var cameraName = cameraFrameDiv.find('span.camera-name').text(); - var frameImg = cameraFrameDiv.find('img.camera'); - var aspectRatio = frameImg.width() / frameImg.height(); - var windowWidth = $(window).width(); - var windowHeight = $(window).height(); - var windowAspectRatio = windowWidth / windowHeight; - var frameIndex = cameraFrameDiv.index(); - var pageContainer = $('div.page-container'); - - if (frameImg.hasClass('error')) { - return; /* no full screen for erroneous cameras */ - } - - var width; - if (windowAspectRatio > aspectRatio) { - width = aspectRatio * Math.round(0.8 * windowHeight); - } - else { - width = Math.round(0.9 * windowWidth); - } - - cameraFrameDiv.find('div.camera-progress').addClass('visible'); - - var cameraImg = cameraFrameDiv.find('img.camera'); - cameraImg.load(function showFullScreenCamera() { - cameraFrameDiv.css('width', width); - fullScreenCameraId = cameraId; - - runModalDialog({ - title: cameraName, - closeButton: true, - content: cameraFrameDiv, - onShow: function () { - cameraImg.unbind('load', showFullScreenCamera); - }, - onClose: function () { - fullScreenCameraId = null; - cameraFrameDiv.css('width', ''); - var nextFrame = pageContainer.children('div:eq(' + frameIndex + ')'); - if (nextFrame.length) { - nextFrame.before(cameraFrameDiv); - } - else { - pageContainer.append(cameraFrameDiv); - } - } - }); - }); - - if (cameraFrameDiv[0].config['proto'] == 'mjpeg') { - /* manually trigger the load event on simple mjpeg cameras */ - cameraImg.load(); - } -} - -function refreshCameraFrames() { - function refreshCameraFrame(cameraId, img, serverSideResize) { - if (refreshDisabled[cameraId]) { - /* camera refreshing disabled, retry later */ - - return; - } - - if (img.loading) { - img.loading++; /* increases each time the camera would refresh but is still loading */ - - if (img.loading > 2 * 1000 / refreshInterval) { /* limits the retries to one every two seconds */ - img.loading = 0; - } - else { - return; /* wait for the previous frame to finish loading */ - } - } - - var timestamp = new Date().getTime(); - var uri = baseUri + 'picture/' + cameraId + '/current/?_=' + timestamp; - if (serverSideResize) { - uri += '&width=' + img.width; - } - - uri = addAuthParams('GET', uri); - - img.src = uri; - img.loading = 1; - } - - var cameraFrames; - if (fullScreenCameraId != null && fullScreenCameraId >= 0) { - cameraFrames = $('#camera' + fullScreenCameraId); - } - else { - cameraFrames = $('div.page-container').find('div.camera-frame'); - } - - cameraFrames.each(function () { - if (!this.img) { - this.img = $(this).find('img.camera')[0]; - if (this.config['proto'] == 'mjpeg') { - var url = this.config['url'].replace('127.0.0.1', window.location.host.split(':')[0]); - url += (url.indexOf('?') > 0 ? '&' : '?') + '_=' + new Date().getTime(); - this.img.src = url; - } - } - - if (this.config['proto'] == 'mjpeg') { - return; /* no manual refresh for simple mjpeg cameras */ - } - - /* at a refresh interval of 50ms, the refresh rate is limited to 20 fps */ - var count = 1000 / (refreshInterval * this.config['streaming_framerate']); - var serverSideResize = this.config['streaming_server_resize']; - - if (count <= 2) { - /* skipping frames (showing the same frame twice) at this rate won't be visible, - * while the effective framerate will be as close as possible to the motion's one */ - count -= 1; - } - - if (this.img.error) { - /* in case of error, decrease the refresh rate to 1 fps */ - count = 1000 / refreshInterval; - } - - if (this.refreshDivider < count) { - this.refreshDivider++; - } - else { - var cameraId = this.id.substring(6); - refreshCameraFrame(cameraId, this.img, serverSideResize); - - this.refreshDivider = 0; - } - }); - - setTimeout(refreshCameraFrames, refreshInterval); -} - -function checkCameraErrors() { - /* properly triggers the onerror event on the cameras whose imgs were not successfully loaded, - * but the onerror event hasn't been triggered, for some reason (seems to happen in Chrome) */ - var cameraFrames = $('div.page-container').find('img.camera'); - - cameraFrames.each(function () { - if (this.complete === true && this.naturalWidth === 0 && !this.error && this.src) { - $(this).error(); - } - }); - - setTimeout(checkCameraErrors, 500); -} - - - /* startup function */ - -$(document).ready(function () { - /* detect base uri */ - if (frame) { - baseUri = qualifyUri('../../../'); - - } - else { - baseUri = splitUrl(qualifyUri('')).baseUrl; - - /* restore the username from cookie */ - window.username = getCookie('username'); - } - - /* open/close settings */ - $('div.settings-button').click(function () { - if (isSettingsOpen()) { - closeSettings(); - } - else { - openSettings(); - } - }); - - /* software update button */ - $('div#updateButton').click(doUpdate); - - /* backup/restore */ - $('div#backupButton').click(doBackup); - $('div#restoreButton').click(doRestore); - - /* prevent scroll events on settings div from propagating TODO this does not actually work */ - $('div.settings').mousewheel(function (e, d) { - var t = $(this); - if (d > 0 && t.scrollTop() === 0) { - e.preventDefault(); - } - else if (d < 0 && (t.scrollTop() === t.get(0).scrollHeight - t.innerHeight())) { - e.preventDefault(); - } - }); - - initUI(); - beginProgress(); - - ajax('GET', baseUri + 'login/', null, function () { - if (!frame) { - fetchCurrentConfig(endProgress); - } - }); - - refreshCameraFrames(); - checkCameraErrors(); -}); - diff --git a/static/js/ui.js b/static/js/ui.js deleted file mode 100644 index 7dfa9bb..0000000 --- a/static/js/ui.js +++ /dev/null @@ -1,1100 +0,0 @@ - -var _modalDialogContexts = []; - - - /* UI widgets */ - -function makeCheckBox($input) { - $input.each(function () { - var $this = $(this); - - var mainDiv = $('
'); - var buttonDiv = $('
'); - var text = $(''); - - function setOn() { - text.html('ON'); - mainDiv.addClass('on'); - } - - function setOff() { - text.html('OFF'); - mainDiv.removeClass('on'); - } - - buttonDiv.append(text); - mainDiv.append(buttonDiv); - - /* transfer the CSS classes */ - mainDiv[0].className += ' ' + $this[0].className; - - /* add the element */ - $this.after(mainDiv); - - function update() { - if ($this[0].checked) { - setOn(); - } - else { - setOff(); - } - } - - /* add event handers */ - $this.change(update).change(); - - mainDiv.click(function () { - $this[0].checked = !$this[0].checked; - $this.change(); - }); - - /* make the element focusable */ - mainDiv[0].tabIndex = 0; - - /* handle the key events */ - mainDiv.keydown(function (e) { - if (e.which === 13 || e.which === 32) { - $this[0].checked = !$this[0].checked; - $this.change(); - - return false; - } - }); - - this.update = update; - }); -} - -function makeSlider($input, minVal, maxVal, snapMode, ticks, ticksNumber, decimals, unit) { - unit = unit || ''; - - $input.each(function () { - var $this = $(this); - var slider = $('
'); - - var labels = $('
'); - slider.append(labels); - - var bar = $('
'); - slider.append(bar); - - bar.append('
'); - - var cursor = $('
'); - bar.append(cursor); - - var cursorLabel = $('
'); - cursor.append(cursorLabel); - - function bestPos(pos) { - if (pos < 0) { - pos = 0; - } - if (pos > 100) { - pos = 100; - } - - if (snapMode > 0) { - var minDif = Infinity; - var bestPos = null; - for (var i = 0; i < ticks.length; i++) { - var tick = ticks[i]; - var p = valToPos(tick.value); - var dif = Math.abs(p - pos); - if ((dif < minDif) && (snapMode == 1 || dif < 5)) { - minDif = dif; - bestPos = p; - } - } - - if (bestPos != null) { - pos = bestPos; - } - } - - return pos; - } - - function getPos() { - return parseInt(cursor.position().left * 100 / bar.width()); - } - - function valToPos(val) { - return (val - minVal) * 100 / (maxVal - minVal); - } - - function posToVal(pos) { - return minVal + pos * (maxVal - minVal) / 100; - } - - function sliderChange(val) { - $this.val(val.toFixed(decimals)); - cursorLabel.html('' + val.toFixed(decimals) + unit); - } - - function bodyMouseMove(e) { - if (bar[0]._mouseDown) { - var offset = bar.offset(); - var pos = e.pageX - offset.left - 5; - pos = pos / slider.width() * 100; - pos = bestPos(pos); - var val = posToVal(pos); - - cursor.css('left', pos + '%'); - sliderChange(val); - } - } - - function bodyMouseUp(e) { - bar[0]._mouseDown = false; - - $('body').unbind('mousemove', bodyMouseMove); - $('body').unbind('mouseup', bodyMouseUp); - - cursorLabel.css('display', 'none'); - - $this.change(); - } - - bar.mousedown(function (e) { - if (e.which > 1) { - return; - } - - this._mouseDown = true; - bodyMouseMove(e); - - $('body').mousemove(bodyMouseMove); - $('body').mouseup(bodyMouseUp); - - slider.focus(); - cursorLabel.css('display', 'inline-block'); - - return false; - }); - - /* ticks */ - var autoTicks = (ticks == null); - - function makeTicks() { - if (ticksNumber == null) { - ticksNumber = 11; - } - - labels.html(''); - - if (autoTicks) { - ticks = []; - var i; - for (i = 0; i < ticksNumber; i++) { - var val = minVal + i * (maxVal - minVal) / (ticksNumber - 1); - var valStr; - if (Math.round(val) == val) { - valStr = '' + val; - } - else { - valStr = val.toFixed(decimals); - } - ticks.push({value: val, label: valStr + unit}); - } - } - - for (i = 0; i < ticks.length; i++) { - var tick = ticks[i]; - var pos = valToPos(tick.value); - var span = $('' + tick.label + ''); - - labels.append(span); - span.css('left', (pos - 10) + '%'); - } - - return ticks; - } - - makeTicks(); - - function input2slider() { - var value = parseFloat($this.val()); - if (isNaN(value)) { - value = minVal; - } - - var pos = valToPos(value); - pos = bestPos(pos); - cursor.css('left', pos + '%'); - cursorLabel.html($this.val() + unit); - } - - /* transfer the CSS classes */ - slider.addClass($this.attr('class')); - - /* handle input events */ - $this.change(input2slider).change(); - - /* add the slider to the parent of the input */ - $this.after(slider); - - /* make the slider focusable */ - slider.attr('tabIndex', 0); - - /* handle key events */ - slider.keydown(function (e) { - switch (e.which) { - case 37: /* left */ - if (snapMode == 1) { /* strict snapping */ - // TODO implement me - } - else { - var step = (maxVal - minVal) / 200; - var val = Math.max(minVal, parseFloat($this.val()) - step); - if (decimals == 0) { - val = Math.floor(val); - } - - var origSnapMode = snapMode; - snapMode = 0; - $this.val(val).change(); - snapMode = origSnapMode; - } - - break; - - case 39: /* right */ - if (snapMode == 1) { /* strict snapping */ - // TODO implement me - } - else { - var step = (maxVal - minVal) / 200; - var val = Math.min(maxVal, parseFloat($this.val()) + step); - if (decimals == 0) { - val = Math.ceil(val); - } - - var origSnapMode = snapMode; - snapMode = 0; - $this.val(val).change(); - snapMode = origSnapMode; - } - - break; - } - }); - - this.update = input2slider; - - slider[0].setMinVal = function (mv) { - minVal = mv; - - makeTicks(); - }; - - slider[0].setMaxVal = function (mv) { - maxVal = mv; - - makeTicks(); - - input2slider(); - }; - }); -} - -function makeProgressBar($div) { - $div.each(function () { - var $this = $(this); - - $this.addClass('progress-bar-container'); - var fillDiv = $('
'); - var textSpan = $(''); - - $this.append(fillDiv); - $this.append(textSpan); - - this.setProgress = function (progress) { - $this.progress = progress; - fillDiv.width(progress + '%'); - }; - - this.setText = function (text) { - textSpan.html(text); - }; - }); -} - - - /* validators */ - -function makeTextValidator($input, required) { - if (required == null) { - required = true; - } - - $input.each(function () { - var $this = $(this); - - function isValid(strVal) { - if (!$this.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } - - if (strVal.length === 0 && required) { - return false; - } - - return true; - } - - var msg = 'this field is required'; - - function validate() { - var strVal = $this.val(); - if (isValid(strVal)) { - $this.attr('title', ''); - $this.removeClass('error'); - $this[0].invalid = false; - } - else { - $this.attr('title', msg); - $this.addClass('error'); - $this[0].invalid = true; - } - } - - $this.keyup(validate); - $this.blur(validate); - $this.change(validate).change(); - - $this.addClass('validator'); - $this.addClass('text-validator'); - $this.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); - } - validate(); - } - }); - }); -} - -function makeComboValidator($select, required) { - if (required == null) { - required = true; - } - - $select.each(function () { - $this = $(this); - - function isValid(strVal) { - if (!$this.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } - - if (strVal.length === 0 && required) { - return false; - } - - return true; - } - - var msg = 'this field is required'; - - function validate() { - var strVal = $this.val() || ''; - if (isValid(strVal)) { - $this.attr('title', ''); - $this.removeClass('error'); - $this[0].invalid = false; - } - else { - $this.attr('title', msg); - $this.addClass('error'); - $this[0].invalid = true; - } - } - - $this.keyup(validate); - $this.blur(validate); - $this.change(validate).change(); - - $this.addClass('validator'); - $this.addClass('combo-validator'); - $this.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); - } - validate(); - } - }); - }); -} - -function makeNumberValidator($input, minVal, maxVal, floating, sign, required) { - if (minVal == null) { - minVal = -Infinity; - } - if (maxVal == null) { - maxVal = Infinity; - } - if (floating == null) { - floating = false; - } - if (sign == null) { - sign = false; - } - if (required == null) { - required = true; - } - - $input.each(function () { - var $this = $(this); - - function isValid(strVal) { - if (!$this.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } - - if (strVal.length === 0 && !required) { - return true; - } - - var numVal = parseInt(strVal); - if ('' + numVal != strVal) { - return false; - } - - if (numVal < minVal || numVal > maxVal) { - return false; - } - - if (!sign && numVal < 0) { - return false; - } - - return true; - } - - var msg = ''; - if (!sign) { - msg = 'enter a positive'; - } - else { - msg = 'enter a'; - } - if (floating) { - msg += ' number'; - } - else { - msg += ' integer number'; - } - if (isFinite(minVal)) { - if (isFinite(maxVal)) { - msg += ' between ' + minVal + ' and ' + maxVal; - } - else { - msg += ' greater than ' + minVal; - } - } - else { - if (isFinite(maxVal)) { - msg += ' smaller than ' + maxVal; - } - } - - function validate() { - var strVal = $this.val(); - if (isValid(strVal)) { - $this.attr('title', ''); - $this.removeClass('error'); - $this[0].invalid = false; - } - else { - $this.attr('title', msg); - $this.addClass('error'); - $this[0].invalid = true; - } - } - - $this.keyup(validate); - $this.blur(validate); - $this.change(validate).change(); - - $this.addClass('validator'); - $this.addClass('number-validator'); - $this.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); - } - validate(); - } - }); - }); - - makeStrippedInput($input); -} - -function makeTimeValidator($input) { - $input.each(function () { - var $this = $(this); - - function isValid(strVal) { - if (!$this.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } - - return strVal.match(new RegExp('^[0-2][0-9]:[0-5][0-9]$')) != null; - } - - var msg = 'enter a valid time in the following format: HH:MM'; - - function validate() { - var strVal = $this.val(); - if (isValid(strVal)) { - $this.attr('title', ''); - $this.removeClass('error'); - $this[0].invalid = false; - } - else { - $this.attr('title', msg); - $this.addClass('error'); - $this[0].invalid = true; - } - } - - $this.keyup(validate); - $this.blur(validate); - $this.change(validate).change(); - $this.timepicker({ - closeOnWindowScroll: true, - selectOnBlur: true, - timeFormat: 'H:i', - }); - - $this.addClass('validator'); - $this.addClass('time-validator'); - $this.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); - } - validate(); - } - }); - }); - - makeStrippedInput($input); -} - -function makeUrlValidator($input) { - $input.each(function () { - var $this = $(this); - - function isValid(strVal) { - if (!$this.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } - - return strVal.match(new RegExp('^([a-zA-Z]+)://([\\w\-.]+)(:\\d+)?(/.*)?$')) != null; - } - - var msg = 'enter a valid URL (e.g. http://example.com:8080/cams/)'; - - function validate() { - var strVal = $this.val(); - if (isValid(strVal)) { - $this.attr('title', ''); - $this.removeClass('error'); - $this[0].invalid = false; - } - else { - $this.attr('title', msg); - $this.addClass('error'); - $this[0].invalid = true; - } - } - - $this.keyup(validate); - $this.blur(validate); - $this.change(validate).change(); - - $this.addClass('validator'); - $this.addClass('url-validator'); - $this.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); - } - validate(); - } - }); - }); -} - -function makeFileValidator($input, required) { - if (required == null) { - required = true; - } - - $input.each(function () { - var $this = $(this); - - function isValid(strVal) { - if (!$this.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } - - if (strVal.length === 0 && required) { - return false; - } - - return true; - } - - var msg = 'this field is required'; - - function validate() { - var strVal = $this.val(); - if (isValid(strVal)) { - $this.attr('title', ''); - $this.removeClass('error'); - $this[0].invalid = false; - } - else { - $this.attr('title', msg); - $this.addClass('error'); - $this[0].invalid = true; - } - } - - $this.keyup(validate); - $this.blur(validate); - $this.change(validate).change(); - - $this.addClass('validator'); - $this.addClass('file-validator'); - $this.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); - } - validate(); - } - }); - }); -} -function makeCustomValidator($input, isValidFunc) { - $input.each(function () { - var $this = $(this); - - function isValid(strVal) { - if (!$this.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } - - return isValidFunc(strVal); - } - - function validate() { - var strVal = $this.val(); - var valid = isValid(strVal); - if (valid == true) { - $this.attr('title', ''); - $this.removeClass('error'); - $this[0].invalid = false; - } - else { - $this.attr('title', valid || 'enter a valid value'); - $this.addClass('error'); - $this[0].invalid = true; - } - } - - $this.keyup(validate); - $this.blur(validate); - $this.change(validate).change(); - - $this.addClass('validator'); - $this.addClass('custom-validator'); - $this.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); - } - validate(); - } - }); - }); -} - - - /* other input value processors */ - -function makeStrippedInput($input) { - $input.change(function () { - this.value = $.trim(this.value); - }); -} - -function makeCharReplacer($input, oldChars, newStr) { - $input.change(function () { - this.value = this.value.replace(new RegExp('[' + oldChars + ']', 'g'), newStr); - }); -} - - - /* modal dialog */ - -function showModalDialog(content, onClose, onShow, stack) { - var glass = $('div.modal-glass'); - var container = $('div.modal-container'); - - if (container.is(':animated')) { - return setTimeout(function () { - showModalDialog(content, onClose, onShow, stack); - }, 100); - } - - if (container.is(':visible') && stack) { - /* the modal dialog is already visible, - * we just replace the content */ - - var children = container.children(':visible'); - _modalDialogContexts.push({ - children: children, - onClose: container[0]._onClose, - }); - - children.css('display', 'none'); - updateModalDialogPosition(); - - container[0]._onClose = onClose; /* set the new onClose handler */ - container.append(content); - updateModalDialogPosition(); - - if (onShow) { - onShow(); - } - - return; - } - - glass.css('display', 'block'); - glass.animate({'opacity': '0.7'}, 200); - - container[0]._onClose = onClose; /* remember the onClose handler */ - container.html(content); - - container.css('display', 'block'); - updateModalDialogPosition(); - container.animate({'opacity': '1'}, 200); - - if (onShow) { - onShow(); - } -} - -function hideModalDialog() { - var glass = $('div.modal-glass'); - var container = $('div.modal-container'); - - if (container.is(':animated')) { - return setTimeout(function () { - hideModalDialog(); - }, 100); - } - - if (_modalDialogContexts.length) { - if (container[0]._onClose) { - container[0]._onClose(); - } - - container.children(':visible').remove(); - - var context = _modalDialogContexts.pop(); - context.children.css('display', ''); - container[0]._onClose = context.onClose; - updateModalDialogPosition(); - - return; - } - - glass.animate({'opacity': '0'}, 200, function () { - glass.css('display', 'none'); - }); - - container.animate({'opacity': '0'}, 200, function () { - container.css('display', 'none'); - container.html(''); - }); - - /* run the onClose handler, if supplied */ - if (container[0]._onClose) { - container[0]._onClose(); - } -} - -function updateModalDialogPosition() { - var container = $('div.modal-container'); - if (!container.is(':visible')) { - return; - } - - var windowWidth = $(window).width(); - var windowHeight = $(window).height(); - var modalWidth, modalHeight, i; - - /* repeat the operation multiple times, the size might change */ - for (i = 0; i < 3; i++) { - modalWidth = container.outerWidth(); - modalHeight = container.outerHeight(); - - container.css('left', Math.floor((windowWidth - modalWidth) / 2)); - container.css('top', Math.floor((windowHeight - modalHeight) / 2)); - } -} - -function makeModalDialogButtons(buttonsInfo) { - /* buttonsInfo is an array of: - * * caption: String - * * isDefault: Boolean - * * click: Function - */ - - var buttonsContainer = $(''); - var tr = buttonsContainer.find('tr'); - - buttonsInfo.forEach(function (info) { - var buttonDiv = $('
'); - - buttonDiv.attr('tabIndex', '0'); /* make button focusable */ - buttonDiv.html(info.caption); - - if (info.isDefault) { - buttonDiv.addClass('default'); - } - - if (info.click) { - var oldClick = info.click; - info.click = function () { - if (oldClick() == false) { - return false; - } - - hideModalDialog(); - - return false; - }; - } - else { - info.click = hideModalDialog; /* every button closes the dialog */ - } - - buttonDiv.click(info.click); - - var td = $(''); - td.append(buttonDiv); - tr.append(td); - }); - - /* limit the size of the buttons container */ - buttonsContainer.css('max-width', (buttonsInfo.length * 10) + 'em'); - - return buttonsContainer; -} - -function makeModalDialogTitleBar(options) { - /* available options: - * * title: String - * * closeButton: Boolean - */ - - var titleBar = $(''); - - var titleSpan = $(''); - titleSpan.html(options.title || ''); - if (options.closeButton) { - titleSpan.css('margin', '0px 1.5em'); - } - - titleBar.append(titleSpan); - - if (options.closeButton) { - var closeButton = $(''); - closeButton.click(hideModalDialog); - titleBar.append(closeButton); - } - - return titleBar; -} - -function runModalDialog(options) { - /* available options: - * * title: String - * * closeButton: Boolean - * * content: any - * * buttons: 'ok'|'yesno'|'okcancel'|Array - * * onYes: Function - * * onNo: Function - * * onOk: Function - * * onCancel: Function - * * onClose: Function - * * onShow: Function - * * stack: Boolean - * * noKeys: Boolean - */ - - var content = $('
'); - var titleBar = null; - var buttonsDiv = null; - var defaultClick = null; - var cancelClick = null; - - /* add title bar */ - if (options.title) { - titleBar = makeModalDialogTitleBar({title: options.title, closeButton: options.closeButton}); - content.append(titleBar); - } - - /* add supplied content */ - if (options.content) { - var contentWrapper = $('
'); - contentWrapper.append(options.content); - content.append(contentWrapper); - } - - /* add buttons */ - if (options.buttons === 'yesno') { - options.buttons = [ - {caption: 'No', click: options.onNo}, - {caption: 'Yes', isDefault: true, click: options.onYes} - ]; - } - if (options.buttons === 'yesnocancel') { - options.buttons = [ - {caption: 'Cancel', isCancel: true, click: options.onCancel}, - {caption: 'No', click: options.onNo}, - {caption: 'Yes', isDefault: true, click: options.onYes} - ]; - } - else if (options.buttons === 'okcancel') { - options.buttons = [ - {caption: 'Cancel', isCancel:true, click: options.onCancel}, - {caption: 'OK', isDefault: true, click: options.onOk} - ]; - } - else if (options.buttons === 'ok') { - options.buttons = [ - {caption: 'OK', isDefault: true, click: options.onOk} - ]; - } - - if (options.buttons) { - buttonsDiv = makeModalDialogButtons(options.buttons); - content.append(buttonsDiv); - - options.buttons.forEach(function (info) { - if (info.isDefault) { - defaultClick = info.click; - } - else if (info.isCancel) { - cancelClick = info.click; - } - }); - } - - /* add some margins */ - if ((buttonsDiv || options.content) && titleBar) { - titleBar.css('margin-bottom', '5px'); - } - - if (buttonsDiv && options.content) { - buttonsDiv.css('margin-top', '5px'); - } - - var handleKeyUp = !options.noKeys && function (e) { - if (!content.is(':visible')) { - return; - } - - switch (e.which) { - case 13: - if (defaultClick && defaultClick() == false) { - return; - } - - hideModalDialog(); - - break; - - case 27: - if (cancelClick && cancelClick() == false) { - return; - } - - hideModalDialog(); - - break; - } - }; - - var onClose = function () { - if (options.onClose) { - options.onClose(); - } - - /* unbind html handlers */ - - $('html').unbind('keyup', handleKeyUp); - }; - - /* bind key handlers */ - $('html').bind('keyup', handleKeyUp); - - /* and finally, show the dialog */ - - showModalDialog(content, onClose, options.onShow, options.stack); - - /* focus the default button if nothing else is focused */ - if (content.find('*:focus').length === 0) { - content.find('div.button.default').focus(); - } -} - - - /* popup message */ - -function showPopupMessage(message, type) { - var container = $('div.popup-message-container'); - var content = $(''); - - if (window._popupMessageTimeout) { - clearTimeout(window._popupMessageTimeout); - } - - content.html(message); - content.addClass(type); - container.html(content); - - var windowWidth = $(window).width(); - var messageWidth = container.width(); - - container.css('display', 'block'); - container.css('left', (windowWidth - messageWidth) / 2); - - container.animate({'opacity': '1'}, 200); - - window._popupMessageTimeout = setTimeout(function () { - window._popupMessageTimeout = null; - container.animate({'opacity': '0'}, 200, function () { - container.css('display', 'none'); - }); - }, 5000); -} diff --git a/static/js/version.js b/static/js/version.js deleted file mode 100644 index 7d43298..0000000 --- a/static/js/version.js +++ /dev/null @@ -1,6 +0,0 @@ - -$(window).load(function () { - if (window.parent && window.parent.postMessage) { - window.parent.postMessage({'hostname': hostname, 'version': version, 'url': window.location.href.replace('version/', '')}, '*'); - } -}); diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index 6679b8d..0000000 --- a/templates/base.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - {% block meta %} - - - - - - {% endblock %} - {% block title %}{% endblock %} - {% block style %} - - - - {% endblock %} - {% block script %} - - - - - - - {% endblock %} - - - - {% block body %}{% endblock %} - - diff --git a/templates/main.html b/templates/main.html deleted file mode 100644 index 199e08f..0000000 --- a/templates/main.html +++ /dev/null @@ -1,826 +0,0 @@ -{% extends "base.html" %} - -{% macro config_item(config) -%} - - {% if config['type'] == 'separator' %} -
- {% else %} - {{config['label']}} - - {% if config['type'] == 'str' %} - - {% elif config['type'] == 'pwd' %} - - {% elif config['type'] == 'number' %} - - {% elif config['type'] == 'range' %} - - {% elif config['type'] == 'bool' %} - - {% elif config['type'] == 'choices' %} - - {% elif config['type'] == 'html' %} -
- {% endif %} - - {% if config.get('description') %}?{% endif %} - {% endif %} - -{%- endmacro %} - -{% block title %}{% if title %}{{title}}{% else %}{{hostname}}{% endif %}{% endblock %} - -{% block style %} - {{super()}} - - - {% if frame %} - - {% endif %} -{% endblock %} - -{% block script %} - {{super()}} - - - {% if frame %} - - {% endif %} - -{% endblock %} - -{% block body %} - {% if not frame %} -
-
-
-
-
- -
-
Apply
- {% if hostname %}
{{hostname}}
{% endif %} -
-
- -
-
- {% endif %} - {% if not frame %} -
-
-
-
- - ? - General Settings - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - {% for config in main_sections.get('general', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} - - - - - - - - {% if enable_update %} - - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - -
Show Advanced Settings?
Administrator Username?
Administrator Password?
Surveillance Username?
Surveillance Password?
Current Version{{version}}
Software Update
Check
?
Power
Shut Down
?
Reboot
?
Configuration
Backup
?
Restore
?
- - {% for section in main_sections.values() %} - {% if section.get('label') and section.get('configs') %} -
- {% if section.get('onoff') %}{% endif %} - {% if section.get('description') %}?{% endif %} - {{section['label']}} - -
- - {% for config in section['configs'] %} - {{config_item(config)}} - {% endfor %} -
- {% endif %} - {% endfor %} - - -
- - -
- - ? - Video Device - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for config in camera_sections.get('device', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} -
Camera Name?
Camera Device
Camera Type
Light Switch Detection?
Automatic Brightness?
Brightness?
Contrast?
Saturation?
Hue?
Video Resolution - - ?
Video Rotation - - ?
Frame Rate?
Extra Motion Options?
- -
- ? - File Storage - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for config in camera_sections.get('storage', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} -
Storage Device - - ?
Network Server?
Share Name?
Share Username?
Share Password?
Root Directory?
Disk Usage -
-
?
- -
- - ? - Text Overlay - -
- - - - - - - - - - - - - - - - - - - - - - {% for config in camera_sections.get('text-overlay', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} -
Left Text - - ?
?
Right Text - - ?
?
- -
- - ? - Video Streaming - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - {% if not old_motion %} - - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - {% for config in camera_sections.get('streaming', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} -
Streaming Frame Rate?
Streaming Quality?
Streaming Image Resizing?
Streaming Resolution?
Streaming Port?
Authentication Mode - - ?
Motion Optimization?
Snapshot URL?
Streaming URL?
Embed URL?
- -
- - ? - Still Images - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for config in camera_sections.get('still-images', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} -
Image File Name?
Image Quality?
Capture Mode - - ?
Snapshot Intervalseconds?
Preserve Pictures - - ?
Pictures Lifetimedays?
- -
- - ? - Motion Detection - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for config in camera_sections.get('motion-detection', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} -
Show Frame Changes?
Frame Change Threshold?
Auto Noise Detection?
Noise Level?
Motion Gapseconds?
Captured Beforeframes?
Captured Afterframes?
Minimum Motion Framesframes?
- -
- - ? - Motion Movies - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - {% for config in camera_sections.get('motion-movies', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} -
Movie File Name?
Movie Quality?
Maximum Movie Lengthseconds?
Preserve Movies - - ?
Movies Lifetimedays?
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for config in camera_sections.get('notifications', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} -
Email Notifications?
Email Addresses?
SMTP Server?
SMTP Port?
SMTP Account?
SMTP Password?
Use TLS?
Attached Pictures Time Spanseconds?
Web Hook Notifications?
Web Hook URL?
HTTP Method - - ?
Run A Command?
Command?
- -
- - ? - Working Schedule - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for config in camera_sections.get('working-schedule', {}).get('configs', []) %} - {{config_item(config)}} - {% endfor %} -
Monday - - from - to - ?
Tuesday - - from - to - ?
Wednesday - - from - to - ?
Thursday - - from - to - ?
Friday - - from - to - ?
Saturday - - from - to - ?
Sunday - - from - to - ?
Detect Motion - - ?
- - {% for section in camera_sections.values() %} - {% if section.get('label') and section.get('configs') %} -
- {% if section.get('onoff') %}{% endif %} - {% if section.get('description') %}?{% endif %} - {{section['label']}} - -
- - {% for config in section['configs'] %} - {{config_item(config)}} - {% endfor %} -
- {% endif %} - {% endfor %} - -
-
-
- -
- -
- {% else %} -
- -
-
-
-
- {% endif %} - - - -{% endblock %} diff --git a/templates/version.html b/templates/version.html deleted file mode 100644 index 3c7a8f8..0000000 --- a/templates/version.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html" %} - -{% block script %} - {{super()}} - - -{% endblock %} - -{% block body %} -hostname = "{{hostname}}"
-version = "{{version}}" -{% endblock %} diff --git a/webhook.py b/webhook.py deleted file mode 100755 index 016a0db..0000000 --- a/webhook.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python - -# Copyright (c) 2013 Calin Crisan -# This file is part of motionEye. -# -# motionEye is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging -import sys -import urllib2 -import urlparse - -import settings - -from motioneye import _configure_settings, _configure_logging - - -_configure_settings() -_configure_logging() - - -def print_usage(): - print 'Usage: webhook.py ' - - -if __name__ == '__main__': - if len(sys.argv) < 3: - print_usage() - sys.exit(-1) - - method = sys.argv[1] - url = sys.argv[2] - - logging.debug('method = %s' % method) - logging.debug('url = %s' % url) - - if method == 'POST': - parts = urlparse.urlparse(url) - data = parts.query - - else: - data = None - - request = urllib2.Request(url, data) - try: - urllib2.urlopen(request, timeout=settings.REMOTE_REQUEST_TIMEOUT) - logging.debug('webhook successfully called') - - except Exception as e: - logging.error('failed to call webhook: %s' % e)