From c5e98e077b9111bf51f448dee18d12b8f4d4e5b0 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sun, 22 Sep 2013 12:08:06 +0300 Subject: [PATCH] more config code --- doc/todo.txt | 4 +- src/config.py | 399 ++++++++++++++++++++++++++++++++--------------- src/handlers.py | 52 +++++- src/motionctl.py | 0 src/server.py | 12 +- src/smbctl.py | 0 src/v4l2ctl.py | 0 7 files changed, 327 insertions(+), 140 deletions(-) create mode 100644 src/motionctl.py create mode 100644 src/smbctl.py create mode 100644 src/v4l2ctl.py diff --git a/doc/todo.txt b/doc/todo.txt index 9cc7c3e..515ffc1 100644 --- a/doc/todo.txt +++ b/doc/todo.txt @@ -1,9 +1,9 @@ --> config.py functions should make use of exceptions rather than returning None - -> browser compatibility test +-> hint text next to section titles -> authentication -> proxy for slave motioneyes -> add a view log functionality -> click to zoom on cameras -> add a previewer for movies -> add a previewer for snapshots +-> other todos \ No newline at end of file diff --git a/src/config.py b/src/config.py index a268f1c..6590e36 100644 --- a/src/config.py +++ b/src/config.py @@ -3,7 +3,6 @@ import errno import json import logging import os.path -import re import settings @@ -17,6 +16,8 @@ _CAMERA_CONFIG_FILE_PATH = os.path.join(_CONFIG_DIR, _CAMERA_CONFIG_FILE_NAME) def get_general(): + # TODO use a cache + config_file_path = os.path.join(settings.PROJECT_PATH, _GENERAL_CONFIG_FILE_PATH) logging.info('reading general config from file %(path)s...' % {'path': config_file_path}) @@ -34,7 +35,7 @@ def get_general(): logging.error('could not open config file %(path)s: %(msg)s' % { 'path': config_file_path, 'msg': unicode(e)}) - return None + raise try: data = json.load(file) @@ -46,13 +47,15 @@ def get_general(): logging.error('could not read config file %(path)s: %(msg)s' % { 'path': config_file_path, 'msg': unicode(e)}) + raise + finally: file.close() - return None - def set_general(data): + # TODO use a cache + _set_default_general(data) config_file_path = os.path.join(settings.PROJECT_PATH, _GENERAL_CONFIG_FILE_PATH) @@ -66,10 +69,10 @@ def set_general(data): logging.error('could not open config file %(path)s for writing: %(msg)s' % { 'path': config_file_path, 'msg': unicode(e)}) - return None + raise try: - json.dump(file, data) + json.dump(data, file) except Exception as e: logging.error('could not write config file %(path)s: %(msg)s' % { @@ -77,70 +80,13 @@ def set_general(data): finally: file.close() - + return data -def get_cameras(): - config_path = os.path.join(settings.PROJECT_PATH, _CONFIG_DIR) - - logging.info('loading cameras from directory %(path)s...' % {'path': config_path}) - - try: - ls = os.listdir(config_path) - - except Exception as e: - logging.error('could not list contents of %(dir)s: %(msg)s' % { - 'dir': config_path, 'msg': unicode(e)}) - - return None - - cameras = {} - - pattern = _CAMERA_CONFIG_FILE_NAME.replace('%(id)s', '(\w+)') - for name in ls: - match = re.match(pattern, name) - if not match: - continue # not a camera config file - - camera_id = match.groups()[0] - - camera_config_path = os.path.join(config_path, name) - - logging.info('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 %(path)s: %(msg)s' % { - 'path': camera_config_path, 'msg': unicode(e)}) - - continue - - try: - lines = [l[:-1] 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)}) - - continue - - finally: - file.close() - - data = _conf_to_dict(lines) - _set_default_motion_camera(data) - - cameras[camera_id] = data - - logging.info('loaded %(count)d cameras' % {'count': len(cameras)}) - - return cameras - - def get_camera(camera_id): + # TODO use a cache + config_path = os.path.join(settings.PROJECT_PATH, _CONFIG_DIR) camera_config_path = os.path.join(config_path, _CAMERA_CONFIG_FILE_NAME % {'id': camera_id}) @@ -152,7 +98,7 @@ def get_camera(camera_id): except Exception as e: logging.error('could not open camera config file: %(msg)s' % {'msg': unicode(e)}) - return None + raise try: lines = [l[:-1] for l in file.readlines()] @@ -161,18 +107,17 @@ def get_camera(camera_id): logging.error('could not read camera config file %(path)s: %(msg)s' % { 'path': camera_config_path, 'msg': unicode(e)}) - return None + raise finally: file.close() - data = _conf_to_dict(lines) - _set_default_motion_camera(data) - - return data + return _conf_to_dict(lines) def set_camera(camera_id, data): + # TODO use a cache + config_path = os.path.join(settings.PROJECT_PATH, _CONFIG_DIR) camera_config_path = os.path.join(config_path, _CAMERA_CONFIG_FILE_NAME % {'id': camera_id}) @@ -187,7 +132,7 @@ def set_camera(camera_id, data): logging.error('could not open camera config file %(path)s: %(msg)s' % { 'path': camera_config_path, 'msg': unicode(e)}) - return None + raise try: lines = [l[:-1] for l in file.readlines()] @@ -196,7 +141,7 @@ def set_camera(camera_id, data): logging.error('could not read camera config file %(path)s: %(msg)s' % { 'path': camera_config_path, 'msg': unicode(e)}) - return None + raise finally: file.close() @@ -212,7 +157,7 @@ def set_camera(camera_id, data): logging.error('could not open camera config file %(path)s for writing: %(msg)s' % { 'path': camera_config_path, 'msg': unicode(e)}) - return None + raise lines = _dict_to_conf(lines, data) @@ -223,7 +168,7 @@ def set_camera(camera_id, data): logging.error('could not write camera config file %(path)s: %(msg)s' % { 'path': camera_config_path, 'msg': unicode(e)}) - return None + raise finally: file.close() @@ -231,41 +176,14 @@ def set_camera(camera_id, data): return data -def add_camera(): - config_path = os.path.join(settings.PROJECT_PATH, _CONFIG_DIR) - - logging.info('loading cameras from directory %(path)s...' % {'path': config_path}) - - try: - ls = os.listdir(config_path) - - except Exception as e: - logging.error('could not list contents of %(dir)s: %(msg)s' % { - 'dir': config_path, 'msg': unicode(e)}) - - return None +def add_camera(device): + # TODO use a cache - camera_ids = [] - - pattern = _CAMERA_CONFIG_FILE_NAME.replace('%(id)s', '(\w+)') - for name in ls: - match = re.match(pattern, name) - if not match: - continue # not a camera config file - - camera_id = match.groups()[0] - try: - camera_id = int(camera_id) - - except ValueError: - logging.error('camera id is not an integer: %(id)s' % {'id': camera_id}) - - continue - - camera_ids.append(camera_id) - - logging.debug('found camera with id %(id)s' % {'id': camera_id}) + # determine the last camera id + cameras = get_general().get('cameras', {}) + camera_ids = [int(k) for k in cameras.iterkeys()] + last_camera_id = max(camera_ids or [0]) camera_id = last_camera_id + 1 @@ -273,6 +191,7 @@ def add_camera(): # write the configuration to file + config_path = os.path.join(settings.PROJECT_PATH, _CONFIG_DIR) camera_config_path = os.path.join(config_path, _CAMERA_CONFIG_FILE_NAME % {'id': camera_id}) logging.info('writing camera config to %(path)s...' % {'path': camera_config_path}) @@ -283,10 +202,11 @@ def add_camera(): logging.error('could not open camera config file %(path)s for writing: %(msg)s' % { 'path': camera_config_path, 'msg': unicode(e)}) - return None + raise - data = {} - _set_default_motion_camera(data) + # add the default camera config + ui = camera_dict_to_ui(camera_id, {}) + data = camera_ui_to_dict(camera_id, ui) lines = _dict_to_conf([], data) @@ -297,15 +217,32 @@ def add_camera(): logging.error('could not write camera config file %(path)s: %(msg)s' % { 'path': camera_config_path, 'msg': unicode(e)}) - return None + raise finally: file.close() - return camera_id, data + # add the camera to the general config + + cameras[camera_id] = { + 'name': 'camera' + str(camera_id), + 'device': device, + 'enabled': True + } + + general_config = get_general() + general_config['cameras'] = cameras + + set_general(general_config) + + return camera_id, cameras[camera_id]['name'], data def rem_camera(camera_id): + # TODO use a cache + + # TODO remove the camera from general config as well + config_path = os.path.join(settings.PROJECT_PATH, _CONFIG_DIR) camera_config_path = os.path.join(config_path, _CAMERA_CONFIG_FILE_NAME % {'id': camera_id}) @@ -318,7 +255,220 @@ def rem_camera(camera_id): logging.error('could not remove camera config file %(path)s: %(msg)s' % { 'path': camera_config_path, 'msg': unicode(e)}) - return None + raise + + +def camera_ui_to_dict(camera_id, ui): + cameras = get_general().get('cameras', {}) + camera_info = cameras.get(camera_id, {}) + camera_name = camera_info.get('name', '(unknown)') + + data = { + # device + 'lightswitch': int(ui.get('light_switch_detect', False) * 5), + 'auto_brightness': ui.get('auto_brightness', False), + 'brightness': int(int(ui.get('brightness', 0)) * 2.55), + 'contrast': int(int(ui.get('contrast', 0)) * 2.55), + 'saturation': int(int(ui.get('saturation', 0)) * 2.55), + 'hue': int(int(ui.get('hue', 0))), + 'width': int(ui.get('resolution', '352x288').split('x')[0]), + 'height': int(ui.get('resolution', '352x288').split('x')[1]), + 'framerate': int(ui.get('framerate', 1)), + + # text overlay + 'text_left': '', + 'text_right': '', + + # streaming + 'webcam_localhost': not ui.get('video_streaming', True), + 'webcam_port': int(ui.get('streaming_port', 8080)), + 'webcam_maxrate': int(ui.get('streaming_framerate', 1)), + 'webcam_quality': max(1, int(ui.get('streaming_quality', 50))), + + # still images + 'output_normal': False, + 'output_all': False, + 'output_motion': False, + 'snapshot_interval': 0, + 'jpeg_filename': '', + 'snapshot_filename': '', + # TODO preserve images + + # movies + 'ffmpeg_variable_bitrate': 0, + 'ffmpeg_video_codec': 'mpeg4', + 'ffmpeg_cap_new': True, + 'movie_filename': '', + # TODO preserve movies + + # motion detection + 'text_changes': ui.get('show_frame_changes', False), + 'locate': ui.get('show_frame_changes', False), + 'threshold': ui.get('frame_change_threshold', 1500), + 'noise_tune': ui.get('auto_noise_detect', True), + 'noise_level': max(1, int(int(ui.get('noise_level', 8)) * 2.55)), + 'gap': int(ui.get('gap', 60)), + 'pre_capture': int(ui.get('pre_capture', 0)), + 'post_capture': int(ui.get('post_capture', 0)), + + # TODO notifications + } + + if ui.get('text_overlay', False): + left_text = ui.get('left_text', 'camera-name') + if left_text == 'camera-name': + data['text_left'] = camera_name + + elif left_text == 'timestamp': + data['text_left'] = '%Y-%m-%d\n%T' + + else: + data['text_left'] = ui.get('custom_left_text', '') + + right_text = ui.get('right_text', 'timestamp') + if right_text == 'camera-name': + data['text_right'] = camera_name + + elif right_text == 'timestamp': + data['text_right'] = '%Y-%m-%d\n%T' + + else: + data['text_right'] = ui.get('custom_right_text', '') + + if ui.get('still_images', False): + capture_mode = ui.get('capture_mode', 'motion-triggered') + if capture_mode == 'motion-triggered': + data['output_normal'] = True + data['jpeg_filename'] = ui.get('image_file_name', '%Y-%m-%d-%H-%M-%S-%q') + + elif capture_mode == 'interval-snapshots': + data['snapshot_interval'] = int(ui.get('snapshot_interval'), 300) + data['snapshot_filename'] = ui.get('image_file_name', '%Y-%m-%d-%H-%M-%S-%q') + + elif capture_mode == 'all-frames': + data['output_all'] = True + data['jpeg_filename'] = ui.get('image_file_name', '%Y-%m-%d-%H-%M-%S') + + data['quality'] = max(1, int(ui.get('image_quality', 75))) + + if ui.get('motion_movies', False): + data['ffmpeg_variable_bitrate'] = 2 + int((100 - int(ui.get('movie_quality', 50))) * 0.29) + data['movie_filename'] = ui.get('movie_file_name', '%Y-%m-%d-%H-%M-%S-%q') + + return data + + +def camera_dict_to_ui(camera_id, data): + # this is where the default values come from + + cameras = get_general().get('cameras', {}) + camera_info = cameras.get(camera_id, {}) + camera_name = camera_info.get('name', '(unknown)') + + ui = { + # device + 'light_switch_detect': data.get('lightswitch', 0) > 0, + 'auto_brightness': data.get('auto_brightness', False), + 'brightness': int(int(data.get('brightness', 0)) / 2.55), + 'contrast': int(int(data.get('contrast', 0)) / 2.55), + 'saturation': int(int(data.get('saturation', 0)) / 2.55), + 'hue': int(int(data.get('hue', 0))), + 'resolution': str(data.get('width', 352)) + 'x' + str(data.get('height', 288)), + 'framerate': int(data.get('framerate', 1)), + + # text overlay + 'text_overlay': False, + 'left_text': 'camera-name', + 'right_text': 'timestamp', + + # streaming + 'vudeo_streaming': not data.get('webcam_localhost', False), + 'streaming_port': int(data.get('webcam_port', 8080)), + 'streaming_framerate': int(data.get('webcam_maxrate', 1)), + 'streaming_quality': int(data.get('webcam_quality', 50)), + + # still images + 'still_images': False, + 'capture_mode': 'motion-triggered', + 'image_file_name': '%Y-%m-%d-%H-%M-%S', + 'image_quality': 75, + # TODO preserve images + + # motion movies + 'motion_movies': False, + 'movie_quality': 50, + 'movie_file_name': '%Y-%m-%d-%H-%M-%S-%q', + # TODO preserve movies + + # motion detection + 'show_frame_changes': data.get('text_changes') or data.get('locate'), + 'frame_change_threshold': data.get('threshold', 1500), + 'auto_noise_detect': data.get('noise_tune', True), + 'noise_level': int(int(data.get('noise_level', 32)) / 2.55), + 'gap': int(data.get('gap', 60)), + 'pre_capture': int(data.get('pre_capture', 0)), + 'post_capture': int(data.get('post_capture', 0)), + + # TODO notifications + } + + text_left = data.get('text_left', '') + text_right = data.get('text_right', '') + if text_left or text_right: + ui['text_overlay'] = True + + if text_left == camera_name: + ui['left_text'] = 'camera-name' + + elif text_left == '%Y-%m-%d\n%T': + ui['left_text'] = 'timestamp' + + else: + ui['left_text'] = 'custom-text' + ui['custom_left_text'] = text_left + + if text_right == camera_name: + ui['right_text'] = 'camera-name' + + elif text_right == '%Y-%m-%d\n%T': + ui['right_text'] = 'timestamp' + + else: + ui['right_text'] = 'custom-text' + ui['custom_right_text'] = text_right + + output_all = data.get('output_all') + output_normal = data.get('output_normal') + jpeg_filename = data.get('jpeg_filename') + snapshot_interval = data.get('snapshot_interval') + snapshot_filename = data.get('snapshpt_filename') + + if (((output_all or output_normal) and jpeg_filename) or + (snapshot_interval and snapshot_filename)): + + ui['still_images'] = True + + if output_all: + ui['capture_mode'] = 'all-frames' + ui['image_file_name'] = jpeg_filename + + elif data.get('snapshot_interval'): + ui['capture-mode'] = 'interval-snapshots' + ui['image_file_name'] = snapshot_filename + + elif data.get('output_normal'): + ui['capture-mode'] = 'motion-triggered' + ui['image_file_name'] = jpeg_filename + + ui['image_quality'] = ui.get('quality', 75) + + movie_filename = data.get('movie_filename') + if movie_filename: + ui['motion_movies'] = True + ui['movie_quality'] = int((max(2, data.get('ffmpeg_variable_bitrate', 14)) - 2) / 0.29) + ui['movie_file_name'] = movie_filename + + return data def _value_to_python(value): @@ -419,17 +569,16 @@ def _dict_to_conf(lines, data): def _set_default_general(data): - data.set_default('show_advanced', False) - data.set_default('admin_username', 'admin') - data.set_default('admin_password', '') - data.set_default('normal_username', 'user') - data.set_default('storage_device', 'local-disk') - data.set_default('root_directory', '/') + data.setdefault('general_enabled', True) + data.setdefault('show_advanced', False) + data.setdefault('admin_username', 'admin') + data.setdefault('admin_password', '') + data.setdefault('normal_username', 'user') + data.setdefault('storage_device', 'local-disk') + data.setdefault('root_directory', '/') + data.setdefault('cameras', {}) def _set_default_motion(data): - pass - + pass # TODO -def _set_default_motion_camera(data): - pass diff --git a/src/handlers.py b/src/handlers.py index dedd2c7..7db78c4 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -4,6 +4,7 @@ import logging from tornado.web import RequestHandler, HTTPError +import config import template @@ -46,24 +47,61 @@ class ConfigHandler(BaseHandler): raise HTTPError(400, 'unknown operation') def get_config(self, camera_id): + general_config = config.get_general() + if camera_id: - logging.debug('getting config for camera %(id)s' % {'camera': camera_id}) + logging.debug('getting config for camera %(id)s' % {'id': camera_id}) + + cameras = general_config.get('cameras', {}) + if camera_id not in cameras: + raise HTTPError(404, 'no such camera') + + self.finish_json(config.get_camera(camera_id)) else: logging.debug('getting general config') + + self.finish_json(general_config) def set_config(self, camera_id): + general_config = config.get_general() + + try: + data = json.loads(self.request.body) + + except Exception as e: + logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)}) + + raise + if camera_id: - logging.debug('setting config for camera %(id)s' % {'camera': camera_id}) + logging.debug('setting config for camera %(id)s' % {'id': camera_id}) + + cameras = general_config.get('cameras', {}) + if camera_id not in cameras: + raise HTTPError(404, 'no such camera') + config.set_camera(camera_id, data) + else: logging.debug('setting general config') + + try: + data = json.loads(self.request.body) + + except Exception as e: + logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)}) + + raise + + general_config.update(data) + config.set_general(general_config) def add_camera(self): logging.debug('adding new camera') def rem_camera(self, camera_id): - logging.debug('removing camera %(id)s' % {'camera': camera_id}) + logging.debug('removing camera %(id)s' % {'id': camera_id}) class SnapshotHandler(BaseHandler): @@ -84,11 +122,11 @@ class SnapshotHandler(BaseHandler): pass def list(self, camera_id): - logging.debug('listing snapshots for camera %(id)s' % {'camera': camera_id}) + logging.debug('listing snapshots for camera %(id)s' % {'id': camera_id}) def download(self, camera_id, filename): logging.debug('downloading snapshot %(filename)s of camera %(id)s' % { - 'filename': filename, 'camera': camera_id}) + 'filename': filename, 'id': camera_id}) class MovieHandler(BaseHandler): @@ -103,8 +141,8 @@ class MovieHandler(BaseHandler): raise HTTPError(400, 'unknown operation') def list(self, camera_id): - logging.debug('listing movies for camera %(id)s' % {'camera': camera_id}) + logging.debug('listing movies for camera %(id)s' % {'id': camera_id}) def download(self, camera_id, filename): logging.debug('downloading movie %(filename)s of camera %(id)s' % { - 'filename': filename, 'camera': camera_id}) + 'filename': filename, 'id': camera_id}) diff --git a/src/motionctl.py b/src/motionctl.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server.py b/src/server.py index 19c228b..9daeb52 100644 --- a/src/server.py +++ b/src/server.py @@ -9,13 +9,13 @@ import template application = Application( [ (r'^/$', handlers.MainHandler), - (r'^/config/(?P\w+)/(?Pget|set|rem)/?$', handlers.ConfigHandler), - (r'^/config/(?Padd)/?$', handlers.ConfigHandler), (r'^/config/general/(?Pset|get)/?$', handlers.ConfigHandler), - (r'^/snapshot/(?P\w+)/(?Pcurrent|list)/?$', handlers.SnapshotHandler), - (r'^/snapshot/(?P\w+)/(?Pdownload)/(?P.+)/?$', handlers.SnapshotHandler), - (r'^/movie/(?P\w+)/(?Plist)/?$', handlers.MovieHandler), - (r'^/movie/(?P\w+)/(?Pdownload)/(?P.+)/?$', handlers.MovieHandler), + (r'^/config/(?P\d+)/(?Pget|set|rem)/?$', handlers.ConfigHandler), + (r'^/config/(?Padd)/?$', handlers.ConfigHandler), + (r'^/snapshot/(?P\d+)/(?Pcurrent|list)/?$', handlers.SnapshotHandler), + (r'^/snapshot/(?P\d+)/(?Pdownload)/(?P.+)/?$', handlers.SnapshotHandler), + (r'^/movie/(?P\d+)/(?Plist)/?$', handlers.MovieHandler), + (r'^/movie/(?P\d+)/(?Pdownload)/(?P.+)/?$', handlers.MovieHandler), ], debug=settings.DEBUG, static_path=settings.STATIC_PATH, diff --git a/src/smbctl.py b/src/smbctl.py new file mode 100644 index 0000000..e69de29 diff --git a/src/v4l2ctl.py b/src/v4l2ctl.py new file mode 100644 index 0000000..e69de29 -- 2.39.5