From 8696d336f52dd4fc422acca79a20b5a6b555cafd Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sun, 22 Feb 2015 19:51:06 +0200 Subject: [PATCH] generalized config UI and backend code --- sendmail.py | 2 +- src/config.py | 203 +++++--- src/handlers.py | 37 +- src/ordereddict.py | 258 ++++++++++ src/tzctl.py | 29 +- src/utils.py | 7 + src/wifictl.py | 111 ++++- static/js/main.js | 401 ++++++++++------ static/js/ui.js | 1091 +++++++++++++++++++++---------------------- templates/main.html | 392 +++++++++------- 10 files changed, 1595 insertions(+), 936 deletions(-) create mode 100644 src/ordereddict.py diff --git a/sendmail.py b/sendmail.py index a720758..2539c06 100755 --- a/sendmail.py +++ b/sendmail.py @@ -102,7 +102,7 @@ def make_message(subject, message, camera_id, moment, timespan, callback): } if settings.LOCAL_TIME_FILE: - format_dict['timezone'] = tzctl.get_time_zone() + format_dict['timezone'] = tzctl._get_time_zone() else: format_dict['timezone'] = 'local time' diff --git a/src/config.py b/src/config.py index 44f7a71..f178857 100644 --- a/src/config.py +++ b/src/config.py @@ -21,17 +21,15 @@ import os.path import re import shlex -from collections import OrderedDict - import diskctl import motionctl import settings import smbctl -import tzctl import update import utils import v4l2ctl -import wifictl + +from utils import OrderedDict _CAMERA_CONFIG_FILE_NAME = 'thread-%(id)s.conf' @@ -40,11 +38,26 @@ _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') +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 @@ -91,12 +104,7 @@ def get_main(as_lines=False): list_names=['thread'], no_convert=['@admin_username', '@admin_password', '@normal_username', '@normal_password']) - if settings.WPA_SUPPLICANT_CONF: - _get_wifi_settings(main_config) - - if settings.LOCAL_TIME_FILE: - _get_localtime_settings(main_config) - + _get_additional_config(main_config, camera=False) _set_default_motion(main_config, old_motion=_is_old_motion()) _main_config_cache = main_config @@ -112,9 +120,8 @@ def set_main(main_config): _main_config_cache = main_config main_config = dict(main_config) - _set_wifi_settings(main_config) - _set_localtime_settings(main_config) - + _set_additional_config(main_config, camera=False) + config_file_path = os.path.join(settings.CONF_PATH, _MAIN_CONFIG_FILE_NAME) # read the actual configuration from file @@ -480,38 +487,46 @@ def rem_camera(camera_id): def main_ui_to_dict(ui): - return { + 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'], - '@time_zone': ui['time_zone'], - - '@wifi_enabled': ui['wifi_enabled'], - '@wifi_name': ui['wifi_name'], - '@wifi_key': ui['wifi_key'], + '@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): - return { + 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'], - 'time_zone': data['@time_zone'], - - 'wifi_enabled': data['@wifi_enabled'], - 'wifi_name': data['@wifi_name'], - 'wifi_key': data['@wifi_key'], + '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 camera_ui_to_dict(ui): data = { @@ -767,6 +782,13 @@ def camera_ui_to_dict(ui): 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 return data @@ -1083,6 +1105,13 @@ def camera_dict_to_ui(data): 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 + return ui @@ -1224,6 +1253,9 @@ def _dict_to_conf(lines, data, list_names=[]): 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: line = name + ' ' + _python_to_value(v) @@ -1276,12 +1308,7 @@ def _set_default_motion(data, old_motion): data.setdefault('@admin_password', '') data.setdefault('@normal_username', 'user') data.setdefault('@normal_password', '') - data.setdefault('@time_zone', 'UTC') - data.setdefault('@wifi_enabled', False) - data.setdefault('@wifi_name', '') - data.setdefault('@wifi_key', '') - if old_motion: data.setdefault('control_port', 7999) @@ -1389,34 +1416,98 @@ def _set_default_motion_camera(camera_id, data, old_motion=False): data.setdefault('on_event_end', '') -def _get_wifi_settings(data): - wifi_settings = wifictl.get_wifi_settings() +def get_additional_structure(camera): + if _additional_structure_cache.get(camera) is None: + logging.debug('loading additional config structure for %s' % ('camera' if camera else 'main')) - data['@wifi_enabled'] = bool(wifi_settings['ssid']) - data['@wifi_name'] = wifi_settings['ssid'] - data['@wifi_key'] = wifi_settings['psk'] + # 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 - -def _set_wifi_settings(data): - wifi_enabled = data.pop('@wifi_enabled', False) - wifi_name = data.pop('@wifi_name', '') - wifi_key = data.pop('@wifi_key', '') + result['name'] = func.func_name + sections[func.func_name] = result - if settings.WPA_SUPPLICANT_CONF: - s = { - 'ssid': wifi_enabled and wifi_name, - 'psk': wifi_key - } - - wifictl.set_wifi_settings(s) + 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 + + result['name'] = func.func_name + configs[func.func_name] = result + + section = sections.setdefault(result.get('section'), {}) + section.setdefault('configs', []).append(result) + + _additional_structure_cache[camera] = sections, configs + + return _additional_structure_cache[camera] -def _get_localtime_settings(data): - time_zone = tzctl.get_time_zone() - data['@time_zone'] = time_zone +def _get_additional_config(data, camera): + (sections, configs) = get_additional_structure(camera) + get_funcs = set([c.get('get') for c in configs.itervalues() if c.get('get')]) + get_func_values = dict((f, f()) 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): + (sections, configs) = get_additional_structure(camera) + + set_func_values = {} + for name, section in sections.iteritems(): + if not section.get('set'): + continue + + if section.get('get_set_dict'): + set_func_values.setdefault(section['set'], {})[name] = data.get('@_' + name) + + else: + set_func_values[section['set']] = data.get('@_' + name) + + for name, config in configs.iteritems(): + if not config.get('set'): + continue + + if config.get('get_set_dict'): + set_func_values.setdefault(config['set'], {})[name] = data.get('@_' + name) + + else: + set_func_values[config['set']] = data.get('@_' + name) -def _set_localtime_settings(data): - time_zone = data.pop('@time_zone') - if time_zone and settings.LOCAL_TIME_FILE: - tzctl.set_time_zone(time_zone) + for func, value in set_func_values.iteritems(): + func(value) diff --git a/src/handlers.py b/src/handlers.py index 9b75f99..f39fcf0 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -141,18 +141,17 @@ class MainHandler(BaseHandler): def get(self): import motioneye - timezones = [] - if settings.LOCAL_TIME_FILE: - import pytz - timezones = pytz.common_timezones + # additional config + main_sections = config.get_additional_structure(camera=False)[0] + camera_sections = config.get_additional_structure(camera=True)[0] self.render('main.html', frame=False, version=motioneye.VERSION, enable_update=bool(settings.REPO), - wpa_supplicant=settings.WPA_SUPPLICANT_CONF, enable_reboot=settings.ENABLE_REBOOT, - timezones=timezones, + main_sections=main_sections, + camera_sections=camera_sections, hostname=socket.gethostname(), admin_username=config.get_main().get('@admin_username')) @@ -293,12 +292,13 @@ class ConfigHandler(BaseHandler): 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', '')) - - wifi_changed = bool([k for k in ['@wifi_enabled', '@wifi_name', '@wifi_key'] if old_main_config.get(k) != main_config.get(k)]) - + + 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 = bool([k for k in reboot_config_names if old_main_config.get(k) != main_config.get(k)]) + config.set_main(main_config) - reboot = False reload = False if admin_credentials != old_admin_credentials: @@ -306,11 +306,11 @@ class ConfigHandler(BaseHandler): reload = True - if wifi_changed: - logging.debug('wifi settings changed, reboot needed') + if reboot: + logging.debug('system settings changed, reboot needed') reboot = True - + return {'reload': reload, 'reboot': reboot} reload = False # indicates that browser should reload the page @@ -322,8 +322,7 @@ class ConfigHandler(BaseHandler): if reboot[0]: if settings.ENABLE_REBOOT: def call_reboot(): - logging.info('rebooting') - os.system('reboot') + powerctl.reboot() ioloop = IOLoop.instance() ioloop.add_timeout(datetime.timedelta(seconds=2), call_reboot) @@ -625,7 +624,13 @@ class ConfigHandler(BaseHandler): event = self.get_argument('event') logging.debug('event %(event)s relayed for camera with id %(id)s' % {'event': event, 'id': camera_id}) - camera_config = config.get_camera(camera_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_camera(camera_config): logging.warn('ignoring event for remote camera with id %s' % camera_id) return self.finish_json() diff --git a/src/ordereddict.py b/src/ordereddict.py new file mode 100644 index 0000000..0874135 --- /dev/null +++ b/src/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/src/tzctl.py b/src/tzctl.py index fbd04e0..24e387d 100644 --- a/src/tzctl.py +++ b/src/tzctl.py @@ -21,6 +21,11 @@ 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 @@ -81,7 +86,7 @@ def _get_time_zone_md5(): return time_zone -def get_time_zone(): +def _get_time_zone(): time_zone = _get_time_zone_symlink() or _get_time_zone_md5() if not time_zone: logging.error('could not find local time zone') @@ -89,7 +94,7 @@ def get_time_zone(): return time_zone -def set_time_zone(time_zone): +def _set_time_zone(time_zone): zoneinfo_file = '/usr/share/zoneinfo/' + time_zone if not os.path.exists(zoneinfo_file): logging.error('%s file does not exist' % zoneinfo_file) @@ -113,3 +118,23 @@ def set_time_zone(time_zone): 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, + 'get': _get_time_zone, + 'set': _set_time_zone + } diff --git a/src/utils.py b/src/utils.py index eb120ab..b79b906 100644 --- a/src/utils.py +++ b/src/utils.py @@ -27,6 +27,13 @@ from tornado.httpclient import AsyncHTTPClient, HTTPRequest import settings +try: + from collections import OrderedDict # @UnusedImport + +except: + from ordereddict import OrderedDict # @UnusedImport @Reimport + + def pretty_date_time(date_time, tzinfo=None, short=False): if date_time is None: return '('+ _('never') + ')' diff --git a/src/wifictl.py b/src/wifictl.py index 8705a8e..dd7dd9f 100644 --- a/src/wifictl.py +++ b/src/wifictl.py @@ -19,22 +19,28 @@ import logging import re import settings +from config import additional_config, additional_section -def get_wifi_settings(): + +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' % settings.WPA_SUPPLICANT_CONF) + + logging.debug('reading wifi settings from %s' % WPA_SUPPLICANT_CONF) try: - conf_file = open(settings.WPA_SUPPLICANT_CONF, 'r') + conf_file = open(WPA_SUPPLICANT_CONF, 'r') except Exception as e: logging.error('could open wifi settings file %(path)s: %(msg)s' % { - 'path': settings.WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) + 'path': WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) return { - 'ssid': None, - 'psk': None + 'wifiEnabled': False, + 'wifiNetworkName': '', + 'wifiNetworkKey': '' } lines = conf_file.readlines() @@ -70,26 +76,27 @@ def get_wifi_settings(): logging.debug('wifi is disabled') return { - 'ssid': ssid, - 'psk': psk + 'wifiEnabled': False, + 'wifiNetworkName': ssid, + 'wifiNetworkKey': psk } -def set_wifi_settings(s): +def _set_wifi_settings(s): # will update the first configured network - logging.debug('writing wifi settings to %s' % settings.WPA_SUPPLICANT_CONF) + logging.debug('writing wifi settings to %s' % WPA_SUPPLICANT_CONF) - enabled = bool(s['ssid']) - ssid = s['ssid'] - psk = s['psk'] + enabled = s['wifiEnabled'] + ssid = s['wifiNetworkName'] + psk = s['wifiNetworkKey'] try: - conf_file = open(settings.WPA_SUPPLICANT_CONF, 'r') + conf_file = open(WPA_SUPPLICANT_CONF, 'r') except Exception as e: logging.error('could open wifi settings file %(path)s: %(msg)s' % { - 'path': settings.WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) + 'path': WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) return @@ -150,11 +157,11 @@ def set_wifi_settings(s): lines.append('}\n\n') try: - conf_file = open(settings.WPA_SUPPLICANT_CONF, 'w') + conf_file = open(WPA_SUPPLICANT_CONF, 'w') except Exception as e: logging.error('could open wifi settings file %(path)s: %(msg)s' % { - 'path': settings.WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) + 'path': WPA_SUPPLICANT_CONF, 'msg': unicode(e)}) return @@ -162,3 +169,71 @@ def set_wifi_settings(s): 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': True, + 'reboot': True, + 'depends': ['wifiEnabled'], + 'get': _get_wifi_settings, + 'set': _set_wifi_settings, + 'get_set_dict': True + } diff --git a/static/js/main.js b/static/js/main.js index 6d3cda1..43c8979 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -303,6 +303,16 @@ Array.prototype.forEach = Array.prototype.forEach || function (callback, thisArg } }; +Array.prototype.every = Array.prototype.every || function (callback, thisArg) { + for (var i = 0; i < this.length; i++) { + if (!callback.call(thisArg, this[i], i, this)) { + return false; + } + } + + return true; +}; + Array.prototype.unique = function (callback, thisArg) { var uniqueElements = []; this.forEach(function (element) { @@ -351,6 +361,18 @@ Array.prototype.sortKey = function (keyFunc, reverse) { }); }; +String.prototype.startsWith = String.prototype.startsWith || function (str) { + return (this.substr(0, str.length) === str); +}; + +String.prototype.endsWith = String.prototype.endsWith || function (str) { + return (this.substr(this.length - str.length) === str); +}; + +String.prototype.trim = String.prototype.trim || function () { + return this.replace(new RegExp('^\\s*'), '').replace(new RegExp('\\s*$'), ''); +}; + String.prototype.replaceAll = String.prototype.replaceAll || function (oldStr, newStr) { var p, s = this; while ((p = s.indexOf(oldStr)) >= 0) { @@ -428,96 +450,48 @@ function doLogout() { function initUI() { /* checkboxes */ - $('input[type=checkbox].styled').each(function () { - makeCheckBox($(this)); - }); + makeCheckBox($('input[type=checkbox].styled')); /* sliders */ - makeSlider($('#brightnessSlider'), 0, 100, 2, null, 5, 0, '%'); - makeSlider($('#contrastSlider'), 0, 100, 2, null, 5, 0, '%'); - makeSlider($('#saturationSlider'), 0, 100, 2, null, 5, 0, '%'); - makeSlider($('#hueSlider'), 0, 100, 2, null, 5, 0, '%'); - makeSlider($('#framerateSlider'), 2, 30, 0, [ - {value: 1, label: '1'}, - {value: 5, label: '5'}, - {value: 10, label: '10'}, - {value: 15, label: '15'}, - {value: 20, label: '20'}, - {value: 25, label: '25'}, - {value: 30, label: '30'} - ], null, 0); - makeSlider($('#streamingFramerateSlider'), 1, 30, 0, [ - {value: 1, label: '1'}, - {value: 5, label: '5'}, - {value: 10, label: '10'}, - {value: 15, label: '15'}, - {value: 20, label: '20'}, - {value: 25, label: '25'}, - {value: 30, label: '30'} - ], null, 0); - makeSlider($('#streamingQualitySlider'), 0, 100, 2, null, 5, 0, '%'); - makeSlider($('#streamingResolutionSlider'), 0, 100, 2, null, 5, 0, '%'); - makeSlider($('#imageQualitySlider'), 0, 100, 2, null, 5, 0, '%'); - makeSlider($('#movieQualitySlider'), 0, 100, 2, null, 5, 0, '%'); - makeSlider($('#frameChangeThresholdSlider'), 0, 20, 0, null, 5, 1, '%'); - - makeSlider($('#noiseLevelSlider'), 0, 25, 0, null, 6, 0, '%'); - + $('input[type=text].range.styled').each(function () { + var $this = $(this); + var $tr = $this.parent().parent(); + var ticks = null; + var ticksAttr = $tr.attr('ticks'); + if (ticksAttr) { + ticks = ticksAttr.split('|').map(function (t) { + var parts = t.split(','); + if (parts.length < 2) { + parts.push(parts[0]); + } + return {value: Number(parts[0]), label: parts[1]}; + }); + } + makeSlider($this, Number($tr.attr('min')), Number($tr.attr('max')), + Number($tr.attr('snap')), ticks, Number($tr.attr('ticksnum')), Number($tr.attr('decimals')), $tr.attr('unit')); + }); + /* progress bars */ - makeProgressBar($('#diskUsageProgressBar')); - + makeProgressBar($('div.progress-bar')); + /* text validators */ - makeTextValidator($('#adminUsernameEntry'), true); - makeTextValidator($('#normalUsernameEntry'), true); - makeTextValidator($('#wifiNameEntry'), true); - makeTextValidator($('#deviceNameEntry'), true); - makeTextValidator($('#networkServerEntry'), true); - makeTextValidator($('#networkShareNameEntry'), true); - makeTextValidator($('#networkUsernameEntry'), false); - makeTextValidator($('#networkPasswordEntry'), false); - makeTextValidator($('#rootDirectoryEntry'), true); - makeTextValidator($('#leftTextEntry'), true); - makeTextValidator($('#rightTextEntry'), true); - makeTextValidator($('#imageFileNameEntry'), true); - makeTextValidator($('#movieFileNameEntry'), true); - makeTextValidator($('#emailAddressesEntry'), true); - makeTextValidator($('#smtpServerEntry'), true); - makeTextValidator($('#smtpAccountEntry'), true); - makeTextValidator($('#smtpPasswordEntry'), true); - makeTextValidator($('#webHookUrlEntry'), true); - makeTextValidator($('#commandNotificationsEntry'), true); - + makeTextValidator($('tr[required=true] input[type=text]'), true); + makeTextValidator($('tr[required=true] input[type=password]'), true); + /* number validators */ - makeNumberValidator($('#streamingPortEntry'), 1024, 65535, false, false, true); - makeNumberValidator($('#snapshotIntervalEntry'), 1, 86400, false, false, true); - makeNumberValidator($('#picturesLifetimeEntry'), 1, 3650, false, false, true); - makeNumberValidator($('#moviesLifetimeEntry'), 1, 3650, false, false, true); - makeNumberValidator($('#eventGapEntry'), 1, 86400, false, false, true); - makeNumberValidator($('#preCaptureEntry'), 0, 100, false, false, true); - makeNumberValidator($('#postCaptureEntry'), 0, 100, false, false, true); - makeNumberValidator($('#minimumMotionFramesEntry'), 1, 1000, false, false, true); - makeNumberValidator($('#smtpPortEntry'), 1, 65535, false, false, true); - makeNumberValidator($('#emailPictureTimeSpanEntry'), 0, 60, false, false, true); - + $('input[type=text].number').each(function () { + var $this = $(this); + var $tr = $this.parent().parent(); + makeTextValidator($this, Number($tr.attr('min')), Number($tr.attr('max')), + Boolean($tr.attr('floating')), Boolean($tr.attr('sign')), Boolean($tr.attr('required'))); + }); + /* time validators */ - makeTimeValidator($('#mondayFromEntry')); - makeTimeValidator($('#mondayToEntry')); - makeTimeValidator($('#tuesdayFromEntry')); - makeTimeValidator($('#tuesdayToEntry')); - makeTimeValidator($('#wednesdayFromEntry')); - makeTimeValidator($('#wednesdayToEntry')); - makeTimeValidator($('#thursdayFromEntry')); - makeTimeValidator($('#thursdayToEntry')); - makeTimeValidator($('#fridayFromEntry')); - makeTimeValidator($('#fridayToEntry')); - makeTimeValidator($('#saturdayFromEntry')); - makeTimeValidator($('#saturdayToEntry')); - makeTimeValidator($('#sundayFromEntry')); - makeTimeValidator($('#sundayToEntry')); + makeTimeValidator($('input[type=text].time')); /* custom validators */ makeCustomValidator($('#rootDirectoryEntry'), function (value) { - if ($('#storageDeviceSelect').val() == 'custom-path' && $.trim(value) == '/') { + if ($('#storageDeviceSelect').val() == 'custom-path' && String(value).trim() == '/') { return 'files cannot be created directly on the root of your system'; } @@ -525,28 +499,12 @@ function initUI() { }, ''); /* input value processors */ - - makeStrippedInput($('#adminUsernameEntry')); - makeStrippedInput($('#adminPasswordEntry')); - makeStrippedInput($('#normalUsernameEntry')); - makeStrippedInput($('#normalPasswordEntry')); - makeStrippedInput($('#deviceNameEntry')); - makeStrippedInput($('#rootDirectoryEntry')); - makeStrippedInput($('#leftTextEntry')); - makeStrippedInput($('#rightTextEntry')); - makeStrippedInput($('#imageFileNameEntry')); - makeStrippedInput($('#movieFileNameEntry')); - makeStrippedInput($('#emailAddressesEntry')); - makeStrippedInput($('#smtpServerEntry')); - makeStrippedInput($('#smtpAccountEntry')); - makeStrippedInput($('#smtpPasswordEntry')); - makeStrippedInput($('#webHookUrlEntry')); - makeStrippedInput($('#commandNotificationsEntry')); - + makeStrippedInput($('tr[strip=true] input[type=text]')); + makeStrippedInput($('tr[strip=true] input[type=password]')); + /* ui elements that enable/disable other ui elements */ $('#motionEyeSwitch').change(updateConfigUi); $('#showAdvancedSwitch').change(updateConfigUi); - $('#wifiSwitch').change(updateConfigUi); $('#storageDeviceSelect').change(updateConfigUi); $('#resolutionSelect').change(updateConfigUi); $('#leftTextSelect').change(updateConfigUi); @@ -574,13 +532,34 @@ function initUI() { $('#fridayEnabledSwitch').change(updateConfigUi); $('#saturdayEnabledSwitch').change(updateConfigUi); $('#sundayEnabledSwitch').change(updateConfigUi); + + /* additional configs */ + var seenDependNames = {}; + $('tr[depends]').each(function () { + var $tr = $(this); + var depends = $tr.attr('depends').split(' '); + depends.forEach(function (depend) { + if (depend.charAt(0) == '!') { + depend = depend.substring(1); + } + + if (depend in seenDependNames) { + return; + } + + seenDependNames[depend] = true; + + var control = $('#' + depend + 'Entry, #' + depend + 'Select, #' + depend + 'Slider, #' + depend + 'Switch'); + control.change(updateConfigUi); + }); + }); $('#storageDeviceSelect').change(function () { $('#rootDirectoryEntry').val('/'); }); $('#rootDirectoryEntry').change(function () { - this.value = $.trim(this.value); + this.value = this.value.trim(); }); $('#rootDirectoryEntry').change(function () { @@ -605,17 +584,8 @@ function initUI() { fetchCurrentCameraConfig(endProgress); } }); - $('input.general, select.general').change(pushMainConfig); - $('input.wifi').change(pushMainConfig); - $('input.device, select.device, ' + - 'input.storage, select.storage, ' + - 'input.text-overlay, select.text-overlay, ' + - 'input.streaming, select.streaming, ' + - 'input.still-images, select.still-images, ' + - 'input.motion-detection, select.motion-detection, ' + - 'input.motion-movies, select.motion-movies, ' + - 'input.notifications, select.notifications, ' + - 'input.working-schedule, select.working-schedule').change(pushCameraConfig); + $('input.main-config, select.main-config').change(pushMainConfig); + $('input.camera-config, select.camera-config').change(pushCameraConfig); /* preview controls */ $('#brightnessSlider').change(function () {pushPreview('brightness');}); @@ -652,7 +622,7 @@ function initUI() { /* logout button */ $('div.button.logout-button').click(doLogout); - /* read-only entries */ + /* autoselect urls in read-only entries */ $('#streamingSnapshotUrlEntry:text, #streamingMjpgUrlEntry:text, #streamingEmbedUrlEntry:text').click(function () { this.select(); }); @@ -712,11 +682,6 @@ function updateConfigUi() { objs.not($('#motionEyeSwitch').parents('div').get(0)).each(markHide); } - /* wifi switch */ - if (!$('#wifiSwitch').get(0).checked) { - $('#wifiSwitch').parent().next('table.settings').find('tr.settings-item').each(markHide); - } - if ($('#cameraSelect').find('option').length < 2) { /* no camera configured */ $('#videoDeviceSwitch').parent().each(markHide); $('#videoDeviceSwitch').parent().nextAll('div.settings-section-title, table.settings').each(markHide); @@ -861,6 +826,48 @@ function updateConfigUi() { $('#workingScheduleSwitch').parent().next('table.settings').find('tr.settings-item').each(markHide); } + /* additional configs */ + $('tr[depends]').each(function () { + var $tr = $(this); + var depends = $tr.attr('depends').split(' '); + var conditionOk = true; + depends.every(function (depend) { + var neg = false; + if (depend.charAt(0) == '!') { + neg = true; + depend = depend.substring(1); + } + + var control = $('#' + depend + 'Entry, #' + depend + 'Select, #' + depend + 'Slider'); + var val = false; + if (control.length) { + val = control.val(); + } + else { /* maybe it's a checkbox */ + control = $('#' + depend + 'Switch'); + if (control.length) { + val = control.get(0).checked; + } + } + + val = Boolean(val); + if (neg) { + val = !val; + } + + if (!val) { + conditionOk = false; + return false; + } + + return true; + }); + + if (!conditionOk) { + $tr.each(markHide); + } + }); + var weekDays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; weekDays.forEach(function (weekDay) { var check = $('#' + weekDay + 'EnabledSwitch'); @@ -917,20 +924,53 @@ function configUiValid() { } function mainUi2Dict() { - return { + var dict = { 'enabled': $('#motionEyeSwitch')[0].checked, 'show_advanced': $('#showAdvancedSwitch')[0].checked, 'admin_username': $('#adminUsernameEntry').val(), 'admin_password': $('#adminPasswordEntry').val(), 'normal_username': $('#normalUsernameEntry').val(), - 'normal_password': $('#normalPasswordEntry').val(), - 'time_zone': $('#timeZoneSelect').val(), - - 'wifi_enabled': $('#wifiSwitch')[0].checked, - 'wifi_name': $('#wifiNameEntry').val(), - 'wifi_key': $('#wifiKeyEntry').val() + 'normal_password': $('#normalPasswordEntry').val() }; + + /* additional sections */ + $('input[type=checkbox].additional-section.main-config').each(function () { + dict['_' + this.id.substring(0, this.id.length - 6)] = this.checked; + }); + + /* additional configs */ + $('tr.additional-config').each(function () { + var $this = $(this); + var control = $this.find('input, select'); + + if (!control.hasClass('main-config')) { + return; + } + + var id = control.attr('id'); + var name, value; + if (id.endsWith('Entry')) { + name = id.substring(0, id.length - 5); + value = control.val(); + } + else if (id.endsWith('Select')) { + name = id.substring(0, id.length - 6); + value = control.val(); + } + else if (id.endsWith('Slider')) { + name = id.substring(0, id.length - 6); + value = control.val(); + } + else if (id.endsWith('Switch')) { + name = id.substring(0, id.length - 6); + value = control[0].checked; + } + + dict['_' + name] = value; + }); + + return dict; } function dict2MainUi(dict) { @@ -941,12 +981,41 @@ function dict2MainUi(dict) { $('#adminPasswordEntry').val(dict['admin_password']); $('#normalUsernameEntry').val(dict['normal_username']); $('#normalPasswordEntry').val(dict['normal_password']); - $('#timeZoneSelect').val(dict['time_zone']); - - $('#wifiSwitch')[0].checked = dict['wifi_enabled']; - $('#wifiNameEntry').val(dict['wifi_name']); - $('#wifiKeyEntry').val(dict['wifi_key']); - + + /* additional sections */ + $('input[type=checkbox].additional-section.main-config').each(function () { + this.checked = dict['_' + this.id.substring(0, this.id.length - 6)]; + }); + + /* additional configs */ + $('tr.additional-config').each(function () { + var $this = $(this); + var control = $this.find('input, select'); + + if (!control.hasClass('main-config')) { + return; + } + + var id = control.attr('id'); + var name; + if (id.endsWith('Entry')) { + name = id.substring(0, id.length - 5); + control.val(dict['_' + name]); + } + else if (id.endsWith('Select')) { + name = id.substring(0, id.length - 6); + control.val(dict['_' + name]); + } + else if (id.endsWith('Slider')) { + name = id.substring(0, id.length - 6); + control.val(dict['_' + name]); + } + else if (id.endsWith('Switch')) { + name = id.substring(0, id.length - 6); + control[0].checked = dict['_' + name]; + } + }); + updateConfigUi(); } @@ -1075,6 +1144,42 @@ function cameraUi2Dict() { dict.hue = $('#hueSlider').val(); } + /* additional sections */ + $('input[type=checkbox].additional-section.camera-config').each(function () { + dict['_' + this.id.substring(0, this.id.length - 6)] = this.checked; + }); + + /* additional configs */ + $('tr.additional-config').each(function () { + var $this = $(this); + var control = $this.find('input, select'); + + if (!control.hasClass('camera-config')) { + return; + } + + var id = control.attr('id'); + var name, value; + if (id.endsWith('Entry')) { + name = id.substring(0, id.length - 5); + value = control.val(); + } + else if (id.endsWith('Select')) { + name = id.substring(0, id.length - 6); + value = control.val(); + } + else if (id.endsWith('Slider')) { + name = id.substring(0, id.length - 6); + value = control.val(); + } + else if (id.endsWith('Switch')) { + name = id.substring(0, id.length - 6); + value = control[0].checked; + } + + dict['_' + name] = value; + }); + return dict; } @@ -1302,6 +1407,40 @@ function dict2CameraUi(dict) { $('#sundayToEntry').val(dict['sunday_to']); $('#workingScheduleTypeSelect').val(dict['working_schedule_type']); + /* additional sections */ + $('input[type=checkbox].additional-section.main-config').each(function () { + this.checked = dict[this.id.substring(0, this.id.length - 6)]; + }); + + /* additional configs */ + $('tr.additional-config').each(function () { + var $this = $(this); + var control = $this.find('input, select'); + + if (!control.hasClass('camera-config')) { + return; + } + + var id = control.attr('id'); + var name; + if (id.endsWith('Entry')) { + name = id.substring(0, id.length - 5); + control.val(dict['_' + name]); + } + else if (id.endsWith('Select')) { + name = id.substring(0, id.length - 6); + control.val(dict['_' + name]); + } + else if (id.endsWith('Slider')) { + name = id.substring(0, id.length - 6); + control.val(dict['_' + name]); + } + else if (id.endsWith('Switch')) { + name = id.substring(0, id.length - 6); + control[0].checked = dict['_' + name]; + } + }); + updateConfigUi(); } diff --git a/static/js/ui.js b/static/js/ui.js index eec9f0e..94cf896 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -5,446 +5,433 @@ var _modalDialogContexts = []; /* UI widgets */ function makeCheckBox($input) { - if (!$input.length) { - return; - } - - 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 += ' ' + $input[0].className; - - /* add the element */ - $input.after(mainDiv); - - function update() { - if ($input[0].checked) { - setOn(); + $input.each(function () { + var $this = $(this); + + var mainDiv = $('
'); + var buttonDiv = $('
'); + var text = $(''); + + function setOn() { + text.html('ON'); + mainDiv.addClass('on'); } - else { - setOff(); + + function setOff() { + text.html('OFF'); + mainDiv.removeClass('on'); } - } - - /* add event handers */ - $input.change(update).change(); - - mainDiv.click(function () { - $input[0].checked = !$input[0].checked; - $input.change(); - }); - - /* make the element focusable */ - mainDiv[0].tabIndex = 0; - - /* handle the key events */ - mainDiv.keydown(function (e) { - if (e.which === 13 || e.which === 32) { - $input[0].checked = !$input[0].checked; - $input.change(); - - return false; + + 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; }); - - $input[0].update = update; - - return mainDiv; } function makeSlider($input, minVal, maxVal, snapMode, ticks, ticksNumber, decimals, unit) { - if (!$input.length) { - return; - } - unit = unit || ''; - - 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; - } + + $input.each(function () { + var $this = $(this); + var slider = $('
'); - 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; - } + 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 (bestPos != null) { - pos = bestPos; + 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) { - $input.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); + return pos; } - } - - function bodyMouseUp(e) { - bar[0]._mouseDown = false; - - $('body').unbind('mousemove', bodyMouseMove); - $('body').unbind('mouseup', bodyMouseUp); - - cursorLabel.css('display', 'none'); - $input.change(); - } - - bar.mousedown(function (e) { - if (e.which > 1) { - return; + function getPos() { + return parseInt(cursor.position().left * 100 / bar.width()); } - this._mouseDown = true; - bodyMouseMove(e); - - $('body').mousemove(bodyMouseMove); - $('body').mouseup(bodyMouseUp); + function valToPos(val) { + return (val - minVal) * 100 / (maxVal - minVal); + } - slider.focus(); - cursorLabel.css('display', 'inline-block'); + function posToVal(pos) { + return minVal + pos * (maxVal - minVal) / 100; + } - return false; - }); - - /* ticks */ - var autoTicks = (ticks == null); - - function makeTicks() { - if (ticksNumber == null) { - ticksNumber = 11; + function sliderChange(val) { + $this.val(val.toFixed(decimals)); + cursorLabel.html('' + val.toFixed(decimals) + unit); } - - 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}); + + 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); } } - for (i = 0; i < ticks.length; i++) { - var tick = ticks[i]; - var pos = valToPos(tick.value); - var span = $('' + tick.label + ''); + function bodyMouseUp(e) { + bar[0]._mouseDown = false; + + $('body').unbind('mousemove', bodyMouseMove); + $('body').unbind('mouseup', bodyMouseUp); + + cursorLabel.css('display', 'none'); - labels.append(span); - span.css('left', (pos - 10) + '%'); + $this.change(); } - return ticks; - } + bar.mousedown(function (e) { + if (e.which > 1) { + return; + } + + this._mouseDown = true; + bodyMouseMove(e); - makeTicks(); - - function input2slider() { - var value = parseFloat($input.val()); - if (isNaN(value)) { - value = minVal; - } + $('body').mousemove(bodyMouseMove); + $('body').mouseup(bodyMouseUp); + + slider.focus(); + cursorLabel.css('display', 'inline-block'); + + return false; + }); - var pos = valToPos(value); - pos = bestPos(pos); - cursor.css('left', pos + '%'); - cursorLabel.html($input.val() + unit); - } - - /* transfer the CSS classes */ - slider.addClass($input.attr('class')); - - /* handle input events */ - $input.change(input2slider).change(); - - /* add the slider to the parent of the input */ - $input.after(slider); - - /* make the slider focusable */ - slider.attr('tabIndex', 0); + /* ticks */ + var autoTicks = (ticks == null); + + function makeTicks() { + if (ticksNumber == null) { + ticksNumber = 11; + } - /* 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($input.val()) - step); - if (decimals == 0) { - val = Math.floor(val); + 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; } - - var origSnapMode = snapMode; - snapMode = 0; - $input.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($input.val()) + step); - if (decimals == 0) { - val = Math.ceil(val); + else { + valStr = val.toFixed(decimals); } - - var origSnapMode = snapMode; - snapMode = 0; - $input.val(val).change(); - snapMode = origSnapMode; + 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 + ''); - break; + labels.append(span); + span.css('left', (pos - 10) + '%'); + } + + return ticks; } - }); + + makeTicks(); - $input.each(function () { + 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].setMinVal = function (mv) { + minVal = mv; - slider[0].setMaxVal = function (mv) { - maxVal = mv; - - makeTicks(); + makeTicks(); + }; - input2slider(); - }; + slider[0].setMaxVal = function (mv) { + maxVal = mv; - return slider; + makeTicks(); + + input2slider(); + }; + }); } function makeProgressBar($div) { - if (!$div.length) { - return; - } - - $div.addClass('progress-bar-container'); - var fillDiv = $('
'); - var textSpan = $(''); - - $div.append(fillDiv); - $div.append(textSpan); - - $div[0].setProgress = function (progress) { - $div.progress = progress; - fillDiv.width(progress + '%'); - }; + $div.each(function () { + var $this = $(this); + + $this.addClass('progress-bar-container'); + var fillDiv = $('
'); + var textSpan = $(''); - $div[0].setText = function (text) { - textSpan.html(text); - }; - - return $div; + $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 (!$input.length) { - return; - } - if (required == null) { required = true; } - - function isValid(strVal) { - if (!$input.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'; + $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; + } - function validate() { - var strVal = $input.val(); - if (isValid(strVal)) { - $input.attr('title', ''); - $input.removeClass('error'); - $input[0].invalid = false; - } - else { - $input.attr('title', msg); - $input.addClass('error'); - $input[0].invalid = true; + return true; } - } - - $input.keyup(validate); - $input.blur(validate); - $input.change(validate).change(); - - $input.addClass('validator'); - $input.addClass('text-validator'); - $input.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); + + 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; } - validate(); } + + $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 (!$select.length) { - return; - } - if (required == null) { required = true; } - - function isValid(strVal) { - if (!$select.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'; + $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; + } - function validate() { - var strVal = $select.val() || ''; - if (isValid(strVal)) { - $select.attr('title', ''); - $select.removeClass('error'); - $select[0].invalid = false; - } - else { - $select.attr('title', msg); - $select.addClass('error'); - $select[0].invalid = true; + return true; } - } - - $select.keyup(validate); - $select.blur(validate); - $select.change(validate).change(); - - $select.addClass('validator'); - $select.addClass('combo-validator'); - $select.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); + + 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; } - validate(); } + + $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 (!$input.length) { - return; - } - if (minVal == null) { minVal = -Infinity; } @@ -460,233 +447,237 @@ function makeNumberValidator($input, minVal, maxVal, floating, sign, required) { if (required == null) { required = true; } - - function isValid(strVal) { - if (!$input.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } - if (strVal.length === 0 && !required) { + $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 numVal = parseInt(strVal); - if ('' + numVal != strVal) { - return false; + var msg = ''; + if (!sign) { + msg = 'enter a positive'; } - - if (numVal < minVal || numVal > maxVal) { - return false; + else { + msg = 'enter a'; } - - 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; + if (floating) { + msg += ' number'; } else { - msg += ' greater than ' + minVal; - } - } - else { - if (isFinite(maxVal)) { - msg += ' smaller than ' + maxVal; + msg += ' integer number'; } - } - - function validate() { - var strVal = $input.val(); - if (isValid(strVal)) { - $input.attr('title', ''); - $input.removeClass('error'); - $input[0].invalid = false; + if (isFinite(minVal)) { + if (isFinite(maxVal)) { + msg += ' between ' + minVal + ' and ' + maxVal; + } + else { + msg += ' greater than ' + minVal; + } } else { - $input.attr('title', msg); - $input.addClass('error'); - $input[0].invalid = true; + if (isFinite(maxVal)) { + msg += ' smaller than ' + maxVal; + } } - } - - $input.keyup(validate); - $input.blur(validate); - $input.change(validate).change(); - - $input.addClass('validator'); - $input.addClass('number-validator'); - $input.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); + + 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; } - validate(); } + + $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) { - if (!$input.length) { - return; - } - - function isValid(strVal) { - if (!$input.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } + $input.each(function () { + var $this = $(this); - 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 isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } - function validate() { - var strVal = $input.val(); - if (isValid(strVal)) { - $input.attr('title', ''); - $input.removeClass('error'); - $input[0].invalid = false; - } - else { - $input.attr('title', msg); - $input.addClass('error'); - $input[0].invalid = true; + return strVal.match(new RegExp('^[0-2][0-9]:[0-5][0-9]$')) != null; } - } - - $input.keyup(validate); - $input.blur(validate); - $input.change(validate).change(); - $input.timepicker({ - closeOnWindowScroll: true, - selectOnBlur: true, - timeFormat: 'H:i', - }); - - $input.addClass('validator'); - $input.addClass('time-validator'); - $input.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); + + 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; } - validate(); } + + $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) { - if (!$input.length) { - return; - } - - function isValid(strVal) { - if (!$input.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } + $input.each(function () { + var $this = $(this); - 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 isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } - function validate() { - var strVal = $input.val(); - if (isValid(strVal)) { - $input.attr('title', ''); - $input.removeClass('error'); - $input[0].invalid = false; + return strVal.match(new RegExp('^([a-zA-Z]+)://([\\w\-.]+)(:\\d+)?(/.*)?$')) != null; } - else { - $input.attr('title', msg); - $input.addClass('error'); - $input[0].invalid = true; - } - } - - $input.keyup(validate); - $input.blur(validate); - $input.change(validate).change(); - - $input.addClass('validator'); - $input.addClass('url-validator'); - $input.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); + + 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; } - validate(); } + + $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 makeCustomValidator($input, isValidFunc) { - if (!$input.length) { - return; - } - - function isValid(strVal) { - if (!$input.is(':visible')) { - return true; /* an invisible element is considered always valid */ - } + $input.each(function () { + var $this = $(this); - return isValidFunc(strVal); - } - - function validate() { - var strVal = $input.val(); - var valid = isValid(strVal); - if (valid == true) { - $input.attr('title', ''); - $input.removeClass('error'); - $input[0].invalid = false; + function isValid(strVal) { + if (!$this.is(':visible')) { + return true; /* an invisible element is considered always valid */ + } + + return isValidFunc(strVal); } - else { - $input.attr('title', valid || 'enter a valid value'); - $input.addClass('error'); - $input[0].invalid = true; + + 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; + } } - } - - $input.keyup(validate); - $input.blur(validate); - $input.change(validate).change(); - $input.addClass('validator'); - $input.addClass('custom-validator'); - $input.each(function () { - var oldValidate = this.validate; - this.validate = function () { - if (oldValidate) { - oldValidate.call(this); + $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(); } - validate(); - } + }); }); } diff --git a/templates/main.html b/templates/main.html index 3716992..0913623 100644 --- a/templates/main.html +++ b/templates/main.html @@ -1,5 +1,44 @@ {% extends "base.html" %} +{% macro config_item(config) -%} + + {{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' %} + + {% endif %} + + {% if config.get('description') %}?{% endif %} + +{%- endmacro %} + {% block title %}{% if title %}{{title}}{% else %}motionEye{% endif %}{% endblock %} {% block style %} @@ -51,44 +90,36 @@
-
General Settings
+
General Settings
- + - + - + - + - + - + - + - + - + - - - - - + {% for config in main_sections.get('general', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} @@ -115,62 +146,60 @@
Show Advanced Settings ?
Administrator Username ?
Administrator Password ?
Surveillance Username ?
Surveillance Password ?
Time Zone - - ?
-
Wireless Network
- - - - - - - - - - - + {% for section in main_sections.values() %} + {% if section.get('label') and section.get('configs') %} +
{% if section.get('onoff') %} + {% endif %}{{section['label']}}
+
Network Name?
Network Key?
+ {% for config in section['configs'] %} + {{config_item(config)}} + {% endfor %}
+ {% endif %} + {% endfor %} -
Video Device
+
Video Device
- + - + - + - + - + - + - + - + - + - + - + - + - + @@ -179,7 +208,7 @@ @@ -187,7 +216,7 @@ - + - + + {% for config in camera_sections.get('device', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %}
Camera Name ?
Camera Device
Light Switch Detection ?
Automatic Brightness ?
Brightness ?
Contrast ?
Saturation ?
Hue ?
Video Resolution - ?
Video Rotation - @@ -196,11 +225,14 @@ ?
Frame Rate ?
File Storage
@@ -208,34 +240,34 @@ Storage Device - ? - + Network Server - + ? - + Share Name - + ? - + Share Username - + ? - + Share Password - + ? - + Root Directory - + ? @@ -244,18 +276,21 @@ Disk Usage -
+
? + {% for config in camera_sections.get('storage', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} -
Text Overlay
+
Text Overlay
- + - + - + - + + {% for config in camera_sections.get('text-overlay', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %}
Left Text - @@ -264,15 +299,15 @@ ?
?
Right Text - @@ -281,78 +316,84 @@ ?
?
-
Video Streaming
+
Video Streaming
- + - + - + - + - + - + - + - + - + - + - + - + - + + {% 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 ?
Motion Optimization ?
Snapshot URL ?
Streaming URL ?
Embed URL ?
-
Still Images
+
Still Images
- + - + - + - + - + - + - + - + + {% for config in camera_sections.get('still-images', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %}
Image File Name ?
Image Quality ?
Capture Mode - @@ -360,15 +401,15 @@ ?
Snapshot Intervalsecondsseconds ?
Preserve Pictures - @@ -379,76 +420,82 @@ ?
Pictures Lifetimedaysdays ?
-
Motion Detection
+
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 Gapsecondsseconds ?
Captured Beforeframesframes ?
Captured Afterframesframes ?
Minimum Motion Framesframesframes ?
-
Motion Movies
+
Motion Movies
- + - + - + - + - + - + + {% for config in camera_sections.get('motion-movies', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %}
Movie File Name ?
Movie Quality ?
Preserve Movies - @@ -459,53 +506,56 @@ ?
Movies Lifetimedaysdays ?
Motion Notifications
- + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -513,18 +563,18 @@ - + - + - + - + - + - + + {% 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 Spansecondsseconds ?
Web Hook Notifications ?
Web Hook URL ?
HTTP Method - @@ -536,92 +586,110 @@
Run A Command ?
Command ?
-
Working Schedule
+
Working Schedule
+ {% for config in camera_sections.get('working-schedule', {}).get('configs', []) %} + {{config_item(config)}} + {% endfor %} +
Monday - - from - to + + from + to ?
Tuesday - - from - to + + from + to ?
Wednesday - - from - to + + from + to ?
Thursday - - from - to + + from + to ?
Friday - - from - to + + from + to ?
Saturday - - from - to + + from + to ?
Sunday - - from - to + + from + to ?
Detect Motion - ?
+ + {% for section in camera_sections.values() %} + {% if section.get('label') and section.get('configs') %} +
{% if section.get('onoff') %} + {% endif %}{{section['label']}}
+ + {% for config in section['configs'] %} + {{config_item(config)}} + {% endfor %}
+ {% endif %} + {% endfor %}
-- 2.39.5