diff --git a/extra/Dockerfile b/extra/Dockerfile index 6b8f480e7..4e5920eb4 100644 --- a/extra/Dockerfile +++ b/extra/Dockerfile @@ -59,6 +59,6 @@ ADD extra/motioneye.conf.sample /usr/share/motioneye/extra/ CMD test -e /etc/motioneye/motioneye.conf || \ cp /usr/share/motioneye/extra/motioneye.conf.sample /etc/motioneye/motioneye.conf ; \ - /usr/local/bin/meyectl startserver -c /etc/motioneye/motioneye.conf -d + /usr/local/bin/meyectl startserver -c /etc/motioneye/motioneye.conf EXPOSE 8765 diff --git a/extra/Dockerfile.armv7-armhf b/extra/Dockerfile.armv7-armhf index 3f22c3639..3768243d3 100644 --- a/extra/Dockerfile.armv7-armhf +++ b/extra/Dockerfile.armv7-armhf @@ -40,6 +40,7 @@ RUN apt-get update && \ git clone --depth 1 https://github.com/Hexxeh/rpi-firmware.git /tmp/rpi-firmware && \ cp -rv /tmp/rpi-firmware/vc/hardfp/opt/vc /opt && \ rm -rf /tmp/rpi-firmware && \ + ln -sf /opt/vc/bin/vcgencmd /usr/bin/vcgencmd && \ curl -L --output /tmp/motion.deb https://github.com/Motion-Project/motion/releases/download/release-4.2.2/pi_stretch_motion_4.2.2-1_armhf.deb && \ dpkg -i /tmp/motion.deb && \ rm /tmp/motion.deb && \ @@ -66,6 +67,6 @@ ADD extra/motioneye.conf.sample /usr/share/motioneye/extra/ CMD test -e /etc/motioneye/motioneye.conf || \ cp /usr/share/motioneye/extra/motioneye.conf.sample /etc/motioneye/motioneye.conf ; \ - /usr/local/bin/meyectl startserver -c /etc/motioneye/motioneye.conf -d + /usr/local/bin/meyectl startserver -c /etc/motioneye/motioneye.conf EXPOSE 8765 diff --git a/extra/docker-compose.yml b/extra/docker-compose.yml index a762c4d47..ff06c8ee0 100644 --- a/extra/docker-compose.yml +++ b/extra/docker-compose.yml @@ -2,7 +2,7 @@ version: "2" services: motioneye: - image: cahna/motioneye + image: ccrisan/motioneye:master-amd64 # Change to ccrisan/motioneye:master-armhf for ARM chips (Pi etc.) ports: - "8081:8081" - "8765:8765" diff --git a/motioneye/config.py b/motioneye/config.py index 854ea1093..f822ee9b5 100644 --- a/motioneye/config.py +++ b/motioneye/config.py @@ -105,6 +105,8 @@ 'text_left', 'text_right', 'threshold', + 'threshold_maximum', + 'threshold_tune', 'videodevice', 'vid_control_params', 'webcontrol_interface', @@ -500,13 +502,6 @@ def add_camera(device_details): 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['path'], '', '', '')) @@ -621,7 +616,6 @@ def rem_camera(camera_id): def main_ui_to_dict(ui): data = { - '@show_advanced': ui['show_advanced'], '@admin_username': ui['admin_username'], '@normal_username': ui['normal_username'] } @@ -666,7 +660,6 @@ def call_hook(u, p): def main_dict_to_ui(data): ui = { - 'show_advanced': data['@show_advanced'], 'admin_username': data['@admin_username'], 'normal_username': data['@normal_username'] } @@ -766,6 +759,8 @@ def motion_camera_ui_to_dict(ui, prev_config=None): 'emulate_motion': False, 'text_changes': ui['show_frame_changes'], 'locate_motion_mode': ui['show_frame_changes'], + 'threshold_maximum': ui['max_frame_change_threshold'], + 'threshold_tune': ui['auto_threshold_tuning'], 'noise_tune': ui['auto_noise_detect'], 'noise_level': max(1, int(round(int(ui['noise_level']) * 2.55))), 'lightswitch_percent': ui['light_switch_detect'], @@ -830,6 +825,7 @@ def motion_camera_ui_to_dict(ui, prev_config=None): 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']) @@ -1133,6 +1129,8 @@ def motion_camera_dict_to_ui(data): 'motion_detection': data['@motion_detection'], 'show_frame_changes': data['text_changes'] or data['locate_motion_mode'], 'auto_noise_detect': data['noise_tune'], + 'max_frame_change_threshold': data['threshold_maximum'], + 'auto_threshold_tuning': data['threshold_tune'], 'noise_level': int(int(data['noise_level']) / 2.55), 'light_switch_detect': data['lightswitch_percent'], 'despeckle_filter': data['despeckle_filter'], @@ -1381,7 +1379,7 @@ def motion_camera_dict_to_ui(data): command_notifications = [] for e in on_event_start: if e.count(' sendmail '): - e = shlex.split(e) + e = shlex.split(utils.make_str(e)) # poor shlex can't deal with unicode properly if len(e) < 10: continue @@ -1405,7 +1403,7 @@ def motion_camera_dict_to_ui(data): ui['email_notifications_picture_time_span'] = 0 elif e.count(' webhook '): - e = shlex.split(e) + e = shlex.split(utils.make_str(e)) # poor shlex can't deal with unicode properly if len(e) < 3: continue @@ -1449,7 +1447,7 @@ def motion_camera_dict_to_ui(data): command_storage = [] for e in on_movie_end: if e.count(' webhook '): - e = shlex.split(e) + e = shlex.split(utils.make_str(e)) # poor shlex can't deal with unicode properly if len(e) < 3: continue @@ -1836,7 +1834,6 @@ def _dict_to_conf(lines, data, list_names=None): def _set_default_motion(data): data.setdefault('@enabled', True) - data.setdefault('@show_advanced', False) data.setdefault('@admin_username', 'admin') data.setdefault('@admin_password', '') data.setdefault('@normal_username', 'user') @@ -1903,6 +1900,8 @@ def _set_default_motion_camera(camera_id, data): data.setdefault('locate_motion_style', 'redbox') data.setdefault('threshold', 2000) + data.setdefault('threshold_maximum', 0) + data.setdefault('threshold_tune', False) data.setdefault('noise_tune', True) data.setdefault('noise_level', 32) data.setdefault('lightswitch_percent', 0) diff --git a/motioneye/handlers.py b/motioneye/handlers.py index e48bf49ff..82754a26e 100644 --- a/motioneye/handlers.py +++ b/motioneye/handlers.py @@ -901,6 +901,9 @@ def authorize(self, camera_id): class PictureHandler(BaseHandler): + def compute_etag(self): + return None + @asynchronous def get(self, camera_id, op, filename=None, group=None): if camera_id is not None: @@ -954,6 +957,9 @@ def post(self, camera_id, op, filename=None, group=None): @BaseHandler.auth(prompt=False) def current(self, camera_id, retry=0): self.set_header('Content-Type', 'image/jpeg') + self.set_header('Cache-Control', 'no-store, must-revalidate') + self.set_header('Pragma', 'no-cache') + self.set_header('Expires', '0') width = self.get_argument('width', None) height = self.get_argument('height', None) @@ -1783,6 +1789,7 @@ def upload_media_file(self, filename, camera_id, camera_config): tasks.add(5, uploadservices.upload_media_file, tag='upload_media_file(%s)' % filename, camera_id=camera_id, service_name=service_name, + camera_name=camera_config['camera_name'], target_dir=camera_config['@upload_subfolders'] and camera_config['target_dir'], filename=filename) diff --git a/motioneye/scripts/relayevent.sh b/motioneye/scripts/relayevent.sh index 0948e0a22..408daa3e9 100755 --- a/motioneye/scripts/relayevent.sh +++ b/motioneye/scripts/relayevent.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [ -z "$3" ]; then echo "Usage: $0 [filename]" diff --git a/motioneye/static/js/frame.js b/motioneye/static/js/frame.js index a474de16b..1a6a8fa0e 100644 --- a/motioneye/static/js/frame.js +++ b/motioneye/static/js/frame.js @@ -29,7 +29,7 @@ function setupCameraFrame() { /* error and load handlers */ cameraImg.error(function () { this.error = true; - this.loading = 0; + this.loading_count = 0; cameraImg.addClass('error').removeClass('loading'); cameraPlaceholder.css('opacity', 1); @@ -42,7 +42,7 @@ function setupCameraFrame() { } this.error = false; - this.loading = 0; + this.loading_count = 0; cameraImg.removeClass('error').removeClass('loading'); cameraPlaceholder.css('opacity', 0); @@ -106,11 +106,11 @@ function refreshCameraFrame() { return; } - if (img.loading) { - img.loading++; /* increases each time the camera would refresh but is still loading */ + if (img.loading_count) { + img.loading_count++; /* 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; + if (img.loading_count > 2 * 1000 / refreshInterval) { /* limits the retry at one every two seconds */ + img.loading_count = 0; } else { return; /* wait for the previous frame to finish loading */ @@ -125,7 +125,7 @@ function refreshCameraFrame() { path = addAuthParams('GET', path); img.src = path; - img.loading = 1; + img.loading_count = 1; cameraFrame.refreshDivider = 0; })(); @@ -141,4 +141,3 @@ $(document).ready(function () { setupCameraFrame(); refreshCameraFrame(); }); - diff --git a/motioneye/static/js/main.js b/motioneye/static/js/main.js index 3bfcc0aa0..4a29348c2 100644 --- a/motioneye/static/js/main.js +++ b/motioneye/static/js/main.js @@ -16,6 +16,10 @@ var username = ''; var passwordHash = ''; var basePath = null; var signatureRegExp = new RegExp('[^a-zA-Z0-9/?_.=&{}\\[\\]":, -]', 'g'); +var deviceNameValidRegExp = new RegExp('^[a-z0-9\-\_\+\ ]+$'); +var filenameValidRegExp = new RegExp('^[a-z0-9_/\\%@\(\)-]*$'); +var dirnameValidRegExp = new RegExp('^[a-z0-9_/\\@\(\)-]*$'); +var emailValidRegExp = new RegExp('^[a-z0-9\-\_\+\.\@\^\~\<>, ]+$'); var initialConfigFetched = false; /* used to workaround browser extensions that trigger stupid change events */ var pageContainer = null; var overlayVisible = false; @@ -571,9 +575,9 @@ function initUI() { makeTimeValidator($('input[type=text].time')); /* custom validators */ - makeCustomValidator($('#adminPasswordEntry'), function (value) { + makeCustomValidator($('#adminPasswordEntry, #normalPasswordEntry'), function (value) { if (!value.toLowerCase().match(new RegExp('^[\x21-\x7F]*$'))) { - return "special characters are not allowed in admin password"; + return "special characters are not allowed in password"; } return true; @@ -583,7 +587,7 @@ function initUI() { return 'this field is required'; } - if (!value.toLowerCase().match(new RegExp('^[a-z0-9\-\_\+\ ]*$'))) { + if (!value.toLowerCase().match(deviceNameValidRegExp)) { return "special characters are not allowed in camera's name"; } @@ -602,6 +606,9 @@ function initUI() { return true; }, ''); makeCustomValidator($('#rootDirectoryEntry'), function (value) { + if (!value.toLowerCase().match(dirnameValidRegExp)) { + return "special characters are not allowed in root directory name"; + } if ($('#storageDeviceSelect').val() == 'custom-path' && String(value).trim() == '/') { return 'files cannot be created directly on the root of your system'; } @@ -609,19 +616,26 @@ function initUI() { return true; }, ''); makeCustomValidator($('#emailFromEntry'), function (value) { - if (value && !value.toLowerCase().match(new RegExp('^[a-z0-9\-\_\+\.\@\^\~\<>, ]+$'))) { + if (value && !value.toLowerCase().match(emailValidRegExp)) { return 'enter a vaild email address'; } return true; }, ''); makeCustomValidator($('#emailAddressesEntry'), function (value) { - if (!value.toLowerCase().match(new RegExp('^[a-z0-9\-\_\+\.\@\^\~\, ]+$'))) { + if (!value.toLowerCase().match(emailValidRegExp)) { return 'enter a list of comma-separated valid email addresses'; } return true; }, ''); + makeCustomValidator($('#imageFileNameEntry, #movieFileNameEntry'), function (value) { + if (!value.toLowerCase().match(filenameValidRegExp)) { + return "special characters are not allowed in file name"; + } + + return true; + }, ''); $('tr[validate] input[type=text]').each(function () { var $this = $(this); var $tr = $this.parent().parent(); @@ -676,13 +690,13 @@ function initUI() { }); /* ui elements that enable/disable other ui elements */ - $('#showAdvancedSwitch').change(updateConfigUI); $('#storageDeviceSelect').change(updateConfigUI); $('#resolutionSelect').change(updateConfigUI); $('#leftTextTypeSelect').change(updateConfigUI); $('#rightTextTypeSelect').change(updateConfigUI); $('#captureModeSelect').change(updateConfigUI); $('#autoNoiseDetectSwitch').change(updateConfigUI); + $('#autoThresholdTuningSwitch').change(updateConfigUI); $('#videoDeviceEnabledSwitch').change(checkMinimizeSection).change(updateConfigUI); $('#textOverlayEnabledSwitch').change(checkMinimizeSection).change(updateConfigUI); $('#videoStreamingEnabledSwitch').change(checkMinimizeSection).change(updateConfigUI); @@ -961,7 +975,7 @@ function initUI() { function addVideoControl(name, min, max, step) { var prevTr = $('#autoBrightnessSwitch').parent().parent(); var controlTr = $('\ - \ + \ \ \ '); @@ -1395,24 +1409,19 @@ function isSettingsOpen() { } function updateConfigUI() { - var objs = $('tr.settings-item, div.advanced-setting, table.advanced-setting, div.settings-section-title, table.settings, ' + + var objs = $('tr.settings-item, div.settings-section-title, table.settings, ' + 'div.check-box.camera-config, div.check-box.main-config'); function markHideLogic() { this._hideLogic = true; } - function markHideAdvanced() { - this._hideAdvanced = true; - } - function markHideMinimized() { this._hideMinimized = true; } function unmarkHide() { this._hideLogic = false; - this._hideAdvanced = false; this._hideMinimized = false; } @@ -1452,12 +1461,6 @@ function updateConfigUI() { $('#videoDeviceEnabledSwitch').parent().nextAll('div.settings-section-title, table.settings').each(markHideLogic); } - /* advanced settings */ - var showAdvanced = $('#showAdvancedSwitch').get(0).checked; - if (!showAdvanced) { - $('tr.advanced-setting, div.advanced-setting, table.advanced-setting').each(markHideAdvanced); - } - /* set resolution to custom if no existing value matches */ if ($('#resolutionSelect')[0].selectedIndex == -1) { $('#resolutionSelect').val('custom'); @@ -1562,7 +1565,7 @@ function updateConfigUI() { for (var i = 0; i < controls.length; i++) { var control = $(controls[i]); var tr = control.parents('tr:eq(0)')[0]; - if (!tr._hideLogic && !tr._hideAdvanced && !tr._hideNull) { + if (!tr._hideLogic && !tr._hideNull) { return; /* has visible controls */ } } @@ -1581,7 +1584,7 @@ function updateConfigUI() { /* filter visible rows */ var visibleTrs = $table.find('tr').filter(function () { - return !this._hideLogic && !this._hideAdvanced && !this._hideNull; + return !this._hideLogic && !this._hideNull; }).map(function () { var $tr = $(this); $tr.isSeparator = $tr.find('div.settings-item-separator').length > 0; @@ -1599,7 +1602,7 @@ function updateConfigUI() { /* filter visible rows again */ visibleTrs = $table.find('tr').filter(function () { - return !this._hideLogic && !this._hideAdvanced && !this._hideNull; + return !this._hideLogic && !this._hideNull; }).map(function () { var $tr = $(this); $tr.isSeparator = $tr.find('div.settings-item-separator').length > 0; @@ -1632,7 +1635,7 @@ function updateConfigUI() { }); objs.each(function () { - if (this._hideLogic || this._hideAdvanced || this._hideMinimized || this._hideNull /* from dict2ui */) { + if (this._hideLogic || this._hideMinimized || this._hideNull /* from dict2ui */) { $(this).hide(200); } else { @@ -1719,7 +1722,6 @@ function savePrefs() { function mainUi2Dict() { var dict = { - 'show_advanced': $('#showAdvancedSwitch')[0].checked, 'admin_username': $('#adminUsernameEntry').val(), 'normal_username': $('#normalUsernameEntry').val() }; @@ -1790,7 +1792,6 @@ function dict2MainUi(dict) { } } - $('#showAdvancedSwitch')[0].checked = dict['show_advanced']; markHideIfNull('show_advanced', 'showAdvancedSwitch'); $('#adminUsernameEntry').val(dict['admin_username']); markHideIfNull('admin_username', 'adminUsernameEntry'); $('#adminPasswordEntry').val(dict['admin_password']); markHideIfNull('admin_password', 'adminPasswordEntry'); $('#normalUsernameEntry').val(dict['normal_username']); markHideIfNull('normal_username', 'normalUsernameEntry'); @@ -1945,6 +1946,8 @@ function cameraUi2Dict() { /* motion detection */ 'motion_detection': $('#motionDetectionEnabledSwitch')[0].checked, 'frame_change_threshold': $('#frameChangeThresholdSlider').val(), + 'max_frame_change_threshold': $('#maxFrameChangeThresholdEntry').val(), + 'auto_threshold_tuning': $('#autoThresholdTuningSwitch')[0].checked, 'auto_noise_detect': $('#autoNoiseDetectSwitch')[0].checked, 'noise_level': $('#noiseLevelSlider').val(), 'light_switch_detect': $('#lightSwitchDetectSlider').val(), @@ -2309,6 +2312,8 @@ function dict2CameraUi(dict) { /* motion detection */ $('#motionDetectionEnabledSwitch')[0].checked = dict['motion_detection']; markHideIfNull('motion_detection', 'motionDetectionEnabledSwitch'); $('#frameChangeThresholdSlider').val(dict['frame_change_threshold']); markHideIfNull('frame_change_threshold', 'frameChangeThresholdSlider'); + $('#maxFrameChangeThresholdEntry').val(dict['max_frame_change_threshold']); markHideIfNull('max_frame_change_threshold', 'maxFrameChangeThresholdEntry'); + $('#autoThresholdTuningSwitch')[0].checked = dict['auto_threshold_tuning']; markHideIfNull('auto_threshold_tuning', 'autoThresholdTuningSwitch'); $('#autoNoiseDetectSwitch')[0].checked = dict['auto_noise_detect']; markHideIfNull('auto_noise_detect', 'autoNoiseDetectSwitch'); $('#noiseLevelSlider').val(dict['noise_level']); markHideIfNull('noise_level', 'noiseLevelSlider'); $('#lightSwitchDetectSlider').val(dict['light_switch_detect']); markHideIfNull('light_switch_detect', 'lightSwitchDetectSlider'); @@ -2338,7 +2343,7 @@ function dict2CameraUi(dict) { $('#webHookNotificationsEnabledSwitch')[0].checked = dict['web_hook_notifications_enabled']; markHideIfNull('web_hook_notifications_enabled', 'webHookNotificationsEnabledSwitch'); $('#webHookNotificationsUrlEntry').val(dict['web_hook_notifications_url']); $('#webHookNotificationsHttpMethodSelect').val(dict['web_hook_notifications_http_method']); - + $('#commandNotificationsEnabledSwitch')[0].checked = dict['command_notifications_enabled']; markHideIfNull('command_notifications_enabled', 'commandNotificationsEnabledSwitch'); $('#commandNotificationsEntry').val(dict['command_notifications_exec']); $('#commandEndNotificationsEnabledSwitch')[0].checked = dict['command_end_notifications_enabled']; markHideIfNull('command_end_notifications_enabled', 'commandEndNotificationsEnabledSwitch'); @@ -2752,7 +2757,7 @@ function doRemCamera() { /* disable further refreshing of this camera */ var img = $('div.camera-frame#camera' + cameraId).find('img.camera'); if (img.length) { - img[0].loading = 1; + img[0].loading_count = 1; } beginProgress(); @@ -4758,7 +4763,7 @@ function addCameraFrameUi(cameraConfig) { /* error and load handlers */ cameraImg[0].onerror = function () { this.error = true; - this.loading = 0; + this.loading_count = 0; cameraImg.addClass('error').removeClass('initializing'); cameraImg.height(Math.round(cameraImg.width() * 0.75)); @@ -4775,7 +4780,7 @@ function addCameraFrameUi(cameraConfig) { this.error = false; } - this.loading = 0; + this.loading_count = 0; if (this.naturalWidth) { this._naturalWidth = this.naturalWidth; } @@ -5053,11 +5058,11 @@ function refreshCameraFrames() { return; } - if (img.loading) { - img.loading++; /* increases each time the camera would refresh but is still loading */ + if (img.loading_count) { + img.loading_count++; /* 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; + if (img.loading_count > 2 * 1000 / refreshInterval) { /* limits the retries to one every two seconds */ + img.loading_count = 0; } else { return; /* wait for the previous frame to finish loading */ @@ -5075,7 +5080,7 @@ function refreshCameraFrames() { path = addAuthParams('GET', path); img.src = path; - img.loading = 1; + img.loading_count = 1; } var cameraFrames; @@ -5198,4 +5203,3 @@ $(document).ready(function () { updateLayout(); }); }); - diff --git a/motioneye/static/js/ui.js b/motioneye/static/js/ui.js index 5101a2a03..338073c89 100644 --- a/motioneye/static/js/ui.js +++ b/motioneye/static/js/ui.js @@ -85,6 +85,8 @@ function makeSlider($input, minVal, maxVal, snapMode, ticks, ticksNumber, decima var cursorLabel = $('
'); cursor.append(cursorLabel); + + var adjusting = false; function bestPos(pos) { if (pos < 0) { @@ -152,6 +154,7 @@ function makeSlider($input, minVal, maxVal, snapMode, ticks, ticksNumber, decima $('body').unbind('mouseup', bodyMouseUp); cursorLabel.css('display', 'none'); + adjusting = false; $this.change(); } @@ -169,6 +172,7 @@ function makeSlider($input, minVal, maxVal, snapMode, ticks, ticksNumber, decima slider.focus(); cursorLabel.css('display', 'inline-block'); + adjusting = true; return false; }); @@ -222,9 +226,21 @@ function makeSlider($input, minVal, maxVal, snapMode, ticks, ticksNumber, decima var pos = valToPos(value); pos = bestPos(pos); cursor.css('left', pos + '%'); - cursorLabel.html($this.val() + unit); + cursorLabel.html(value.toFixed(decimals) + unit); } + /* show / hide cursor label tooltip */ + cursor.mouseenter(function (e) { + if (!adjusting) { + cursorLabel.css('display', 'inline-block'); + } + }); + cursor.mouseleave(function (e) { + if (!adjusting) { + cursorLabel.css('display', 'none'); + } + }); + /* transfer the CSS classes */ slider.addClass($this.attr('class')); diff --git a/motioneye/templates/main.html b/motioneye/templates/main.html index 17ef42ffe..db6609031 100644 --- a/motioneye/templates/main.html +++ b/motioneye/templates/main.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% macro config_item(config, depends="") -%} - - +
@@ -149,11 +149,6 @@ - - - - - @@ -177,47 +172,47 @@ {% for config in main_sections.get('general', {}).get('configs', []) %} {{config_item(config)}} {% endfor %} - + - + - + - + {% if enable_update %} - + {% endif %} - + - + - + - + - + @@ -227,13 +222,13 @@ {% 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']}}
-
Advanced Settings?
Admin Username
motionEye Version {{version}}
Motion Version {{motion_version}}
OS Version {{os_version}}
Software Update
Check
?
Power
Shut Down
?
Reboot
?
Configuration
Backup
?
Restore
?
+
{% for config in section['configs'] %} {{config_item(config)}} {% endfor %} @@ -241,7 +236,7 @@ {% endif %} {% endfor %} - + @@ -263,26 +258,26 @@ - + - + - + - + - + - + - + - + - + @@ -328,13 +323,13 @@
?
Camera Device
Camera Type
Automatic Brightness ?
Video Resolution ?
Video Rotation ?
Frame Rate ?
Extra Motion Options ?
-
+ - - +
+ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -535,14 +531,14 @@
Storage Device ?
Network Server ?
SMB Protocol Version ?
Share Name ?
Share Username ?
Share Password ?
Root Directory ?
Test Share
?
Disk Usage
?
Upload Media Files ?
Upload Pictures ?
Upload Movies ?
Upload Service ?
Server Address ?
Server Port ?
Method ?
Location ?
Include Subfolders ?
Clean Cloud ?
Username ?
Password ?
Authorization Key ?
@@ -486,25 +482,25 @@
?
Test Service
?
Call A Web Hook ?
Web Hook URL ?
HTTP Method ?
Run A Command ?
Command ?
-
+ - - +
+ - + - + - + @@ -594,32 +590,32 @@
Left Text ?
?
Right Text ?
?
- + - + - + - + - + - + - + - + - + - + - +
Streaming Frame Rate ?
Streaming Quality ?
Streaming Image Resizing ?
Streaming Resolution ?
Streaming Port ?
Authentication Mode ?
Motion Optimization ?
Useful URLs
@@ -647,7 +643,7 @@
?
@@ -656,7 +652,7 @@
?
@@ -678,17 +674,17 @@
- + - + - + - + @@ -729,7 +725,7 @@ - + @@ -747,12 +743,12 @@
Image File Name ?
Image Quality ?
Capture Mode
Snapshot Interval seconds ?days ?
Enable Manual Snapshots ?
- + - + - + - + - + @@ -819,70 +815,80 @@
Movie File Name ?
Movie Format ?
Movie Quality ?
Recording Mode ?
Maximum Movie Length seconds ?
-
+ - - +
+ - + + + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -930,76 +936,76 @@
Frame Change Threshold ?
Maximum Change Thresholdpixels?
Auto Threshold Tuning?
Auto Noise Detection ?
Noise Level ?
Light Switch Detection ?
Despeckle Filter ?
Motion Gap seconds ?
Captured Before frames ?
Captured After frames ?
Minimum Motion Frames frames ?
Mask ?
Mask Type ?
Smart Mask Sluggishness ?
Edit Mask
@@ -906,20 +912,20 @@
?
Clear Mask
?
Show Frame Changes ?
Create Debug Media Files ?
-
+ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -1131,7 +1137,7 @@ {% 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']}} diff --git a/motioneye/tzctl.py b/motioneye/tzctl.py index e85710ff7..e55b04dc8 100644 --- a/motioneye/tzctl.py +++ b/motioneye/tzctl.py @@ -132,7 +132,6 @@ def timeZone(): '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/uploadservices.py b/motioneye/uploadservices.py index 6a9ab38f8..b4ebc1104 100644 --- a/motioneye/uploadservices.py +++ b/motioneye/uploadservices.py @@ -29,7 +29,7 @@ import settings import utils import config - +import datetime _STATE_FILE_NAME = 'uploadservices.json' _services = None @@ -53,7 +53,9 @@ def get_authorize_url(cls): def test_access(self): return True - def upload_file(self, target_dir, filename): + def upload_file(self, target_dir, filename, camera_name): + ctime = os.path.getctime(filename) + if target_dir: target_dir = os.path.realpath(target_dir) rel_filename = os.path.realpath(filename) @@ -98,11 +100,11 @@ def upload_file(self, target_dir, filename): mime_type = mimetypes.guess_type(filename)[0] or 'image/jpeg' self.debug('mime type of "%s" is "%s"' % (filename, mime_type)) - self.upload_data(rel_filename, mime_type, data) + self.upload_data(rel_filename, mime_type, data, ctime, camera_name) self.debug('file "%s" successfully uploaded' % filename) - def upload_data(self, filename, mime_type, data): + def upload_data(self, filename, mime_type, data, ctime, camera_name): pass def dump(self): @@ -140,8 +142,7 @@ def get_service_classes(): return {c.NAME: c for c in UploadService.__subclasses__()} -class GoogleDrive(UploadService): - NAME = 'gdrive' +class GoogleBase: AUTH_URL = 'https://accounts.google.com/o/oauth2/auth' TOKEN_URL = 'https://accounts.google.com/o/oauth2/token' @@ -149,27 +150,15 @@ class GoogleDrive(UploadService): CLIENT_ID = '349038943026-m16svdadjrqc0c449u4qv71v1m1niu5o.apps.googleusercontent.com' CLIENT_NOT_SO_SECRET = 'jjqbWmICpA0GvbhsJB3okX7s' - SCOPE = 'https://www.googleapis.com/auth/drive' - CHILDREN_URL = 'https://www.googleapis.com/drive/v2/files/%(parent_id)s/children?q=%(query)s' - CHILDREN_QUERY = "'%(parent_id)s' in parents and title = '%(child_name)s' and trashed = false" - UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v2/files?uploadType=multipart' - CREATE_FOLDER_URL = 'https://www.googleapis.com/drive/v2/files' - - BOUNDARY = 'motioneye_multipart_boundary' - - FOLDER_ID_LIFE_TIME = 300 # 5 minutes - - def __init__(self, camera_id): + def _init(self): self._location = None self._authorization_key = None self._credentials = None self._folder_ids = {} self._folder_id_times = {} - UploadService.__init__(self, camera_id) - @classmethod - def get_authorize_url(cls): + def _get_authorize_url(cls): query = { 'scope': cls.SCOPE, 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob', @@ -180,7 +169,7 @@ def get_authorize_url(cls): return cls.AUTH_URL + '?' + urllib.urlencode(query) - def test_access(self): + def _test_access(self): try: self._folder_ids = {} self._get_folder_id() @@ -189,44 +178,14 @@ def test_access(self): except Exception as e: return str(e) - def upload_data(self, filename, mime_type, data): - path = os.path.dirname(filename) - filename = os.path.basename(filename) - - metadata = { - 'title': filename, - 'parents': [{'id': self._get_folder_id(path)}] - } - - body = ['--' + self.BOUNDARY] - body.append('Content-Type: application/json; charset=UTF-8') - body.append('') - body.append(json.dumps(metadata)) - body.append('') - - body.append('--' + self.BOUNDARY) - body.append('Content-Type: %s' % mime_type) - body.append('') - body.append('') - body = '\r\n'.join(body) - body += data - body += '\r\n--%s--' % self.BOUNDARY - - headers = { - 'Content-Type': 'multipart/related; boundary="%s"' % self.BOUNDARY, - 'Content-Length': len(body) - } - - self._request(self.UPLOAD_URL, body, headers) - - def dump(self): + def _dump(self): return { 'location': self._location, 'credentials': self._credentials, 'authorization_key': self._authorization_key, } - def load(self, data): + def _load(self, data): if data.get('location'): self._location = data['location'] self._folder_ids = {} @@ -236,92 +195,6 @@ def load(self, data): if data.get('credentials'): self._credentials = data['credentials'] - def _get_folder_id(self, path=''): - now = time.time() - - folder_id = self._folder_ids.get(path) - folder_id_time = self._folder_id_times.get(path, 0) - - location = self._location - if not location.endswith('/'): - location += '/' - - location += path - - if not folder_id or (now - folder_id_time > self.FOLDER_ID_LIFE_TIME): - self.debug('finding folder id for location "%s"' % location) - folder_id = self._get_folder_id_by_path(location) - - self._folder_ids[path] = folder_id - self._folder_id_times[path] = now - - return folder_id - - def _get_folder_id_by_path(self, path): - if path and path != '/': - path = [p.strip() for p in path.split('/') if p.strip()] - parent_id = 'root' - for name in path: - parent_id = self._get_folder_id_by_name(parent_id, name) - - return parent_id - - else: # root folder - return self._get_folder_id_by_name(None, 'root') - - def _get_folder_id_by_name(self, parent_id, child_name, create=True): - if parent_id: - query = self.CHILDREN_QUERY % {'parent_id': parent_id, 'child_name': child_name} - query = urllib.quote(query) - - else: - query = '' - - parent_id = parent_id or 'root' - # when requesting the id of the root folder, we perform a dummy request, - # event though we already know the id (which is "root"), to test the request - - url = self.CHILDREN_URL % {'parent_id': parent_id, 'query': query} - response = self._request(url) - try: - response = json.loads(response) - - except Exception: - self.error("response doesn't seem to be a valid json") - raise - - if parent_id == 'root' and child_name == 'root': - return 'root' - - items = response.get('items') - if not items: - if create: - self.debug('folder with name "%s" does not exist, creating it' % child_name) - self._create_folder(parent_id, child_name) - return self._get_folder_id_by_name(parent_id, child_name, create=False) - - else: - msg = 'folder with name "%s" does not exist' % child_name - self.error(msg) - raise Exception(msg) - - return items[0]['id'] - - def _create_folder(self, parent_id, child_name): - metadata = { - 'title': child_name, - 'parents': [{'id': parent_id}], - 'mimeType': 'application/vnd.google-apps.folder' - } - - body = json.dumps(metadata) - - headers = { - 'Content-Type': 'application/json; charset=UTF-8' - } - - self._request(self.CREATE_FOLDER_URL, body, headers) - def _request(self, url, body=None, headers=None, retry_auth=True, method=None): if not self._credentials: if not self._authorization_key: @@ -379,6 +252,16 @@ def _request(self, url, body=None, headers=None, retry_auth=True, method=None): return response.read() + def _request_json(self, url, body=None, headers=None, retry_auth=True, method=None): + response = self._request(url, body, headers, retry_auth, method) + try: + response = json.loads(response) + except Exception: + self.error("reponse doesn't seem to be a valid json") + raise + + return response + def _request_credentials(self, authorization_key): headers = { 'Content-Type': 'application/x-www-form-urlencoded' @@ -439,6 +322,154 @@ def _refresh_credentials(self, refresh_token): 'refresh_token': data.get('refresh_token', refresh_token) } + +class GoogleDrive(UploadService, GoogleBase): + NAME = 'gdrive' + + SCOPE = 'https://www.googleapis.com/auth/drive' + CHILDREN_URL = 'https://www.googleapis.com/drive/v2/files/%(parent_id)s/children?q=%(query)s' + CHILDREN_QUERY = "'%(parent_id)s' in parents and title = '%(child_name)s' and trashed = false" + UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v2/files?uploadType=multipart' + CREATE_FOLDER_URL = 'https://www.googleapis.com/drive/v2/files' + + BOUNDARY = 'motioneye_multipart_boundary' + + FOLDER_ID_LIFE_TIME = 300 # 5 minutes + + def __init__(self, camera_id): + self._init() + + UploadService.__init__(self, camera_id) + + @classmethod + def get_authorize_url(cls): + return cls._get_authorize_url() + + def test_access(self): + return self._test_access() + + def upload_data(self, filename, mime_type, data, ctime, camera_name): + path = os.path.dirname(filename) + filename = os.path.basename(filename) + + metadata = { + 'title': filename, + 'parents': [{'id': self._get_folder_id(path)}] + } + + body = ['--' + self.BOUNDARY] + body.append('Content-Type: application/json; charset=UTF-8') + body.append('') + body.append(json.dumps(metadata)) + body.append('') + + body.append('--' + self.BOUNDARY) + body.append('Content-Type: %s' % mime_type) + body.append('') + body.append('') + body = '\r\n'.join(body) + body += data + body += '\r\n--%s--' % self.BOUNDARY + + headers = { + 'Content-Type': 'multipart/related; boundary="%s"' % self.BOUNDARY, + 'Content-Length': len(body) + } + + self._request(self.UPLOAD_URL, body, headers) + + def dump(self): + return self._dump() + + def load(self, data): + self._load(data) + + def _get_folder_id(self, path=''): + now = time.time() + + folder_id = self._folder_ids.get(path) + folder_id_time = self._folder_id_times.get(path, 0) + + location = self._location + if not location.endswith('/'): + location += '/' + + location += path + + if not folder_id or (now - folder_id_time > self.FOLDER_ID_LIFE_TIME): + self.debug('finding folder id for location "%s"' % location) + folder_id = self._get_folder_id_by_path(location) + + self._folder_ids[path] = folder_id + self._folder_id_times[path] = now + + return folder_id + + def _get_folder_id_by_path(self, path): + if path and path != '/': + path = [p.strip() for p in path.split('/') if p.strip()] + parent_id = 'root' + for name in path: + parent_id = self._get_folder_id_by_name(parent_id, name) + + return parent_id + + else: # root folder + return self._get_folder_id_by_name(None, 'root') + + def _get_folder_id_by_name(self, parent_id, child_name, create=True): + if parent_id: + query = self.CHILDREN_QUERY % {'parent_id': parent_id, 'child_name': child_name} + query = urllib.quote(query) + + else: + query = '' + + parent_id = parent_id or 'root' + # when requesting the id of the root folder, we perform a dummy request, + # event though we already know the id (which is "root"), to test the request + + url = self.CHILDREN_URL % {'parent_id': parent_id, 'query': query} + response = self._request(url) + try: + response = json.loads(response) + + except Exception: + self.error("response doesn't seem to be a valid json") + raise + + if parent_id == 'root' and child_name == 'root': + return 'root' + + items = response.get('items') + if not items: + if create: + self.debug('folder with name "%s" does not exist, creating it' % child_name) + self._create_folder(parent_id, child_name) + return self._get_folder_id_by_name(parent_id, child_name, create=False) + + else: + msg = 'folder with name "%s" does not exist' % child_name + self.error(msg) + raise Exception(msg) + + return items[0]['id'] + + def _create_folder(self, parent_id, child_name): + metadata = { + 'title': child_name, + 'parents': [{'id': parent_id}], + 'mimeType': 'application/vnd.google-apps.folder' + } + + body = json.dumps(metadata) + + headers = { + 'Content-Type': 'application/json; charset=UTF-8' + } + + self._request(self.CREATE_FOLDER_URL, body, headers) + def clean_cloud(self, cloud_dir, local_folders): # remove old cloud folder that does not exist in local. # assumes 'cloud_dir' is a direct child of the 'root'. @@ -454,7 +485,7 @@ def clean_cloud(self, cloud_dir, local_folders): name = self._get_file_title(id) self.debug("cloud '%s'" % name) to_delete = not exist_in_local(name, local_folders) - if to_delete and self._delete_child(folder_id, id): + if to_delete and self._delete_file(id): removed_count += 1 self.info("deleted a cloud folder '%s'" % name) @@ -474,8 +505,8 @@ def _get_children(self, file_id): return response['items'] - def _delete_child(self, folder_id, child_id): - url = '%s/%s/children/%s' % (self.CREATE_FOLDER_URL, folder_id, child_id) + def _delete_file(self, file_id): + url = '%s/%s' % (self.CREATE_FOLDER_URL, file_id) response = self._request(url, None, None, True, 'DELETE') succeeded = response == "" return succeeded @@ -497,6 +528,136 @@ def _get_file_title(self, file_id): return self._get_file_metadata(file_id)['title'] +class GooglePhoto(UploadService, GoogleBase): + NAME = 'gphoto' + + SCOPE = 'https://www.googleapis.com/auth/photoslibrary' + GOOGLE_PHOTO_API = 'https://photoslibrary.googleapis.com/v1/' + + def __init__(self, camera_id): + self._init() + + UploadService.__init__(self, camera_id) + + @classmethod + def get_authorize_url(cls): + return cls._get_authorize_url() + + def test_access(self): + return self._test_access() + + def upload_data(self, filename, mime_type, data, ctime, camera_name): + path = os.path.dirname(filename) + filename = os.path.basename(filename) + dayinfo = datetime.datetime.fromtimestamp(ctime).strftime('%Y-%m-%d') + uploadname = dayinfo + '-' + filename + + body = data + + headers = { + 'Content-Type': 'application/octet-stream', + 'X-Goog-Upload-File-Name': uploadname, + 'X-Goog-Upload-Protocol': 'raw' + } + + uploadToken = self._request(self.GOOGLE_PHOTO_API + 'uploads', body, headers) + response = self._create_media(uploadToken, camera_name) + self.debug('response %s' % response['mediaItem']) + + def dump(self): + return self._dump() + + def load(self, data): + self._load(data) + + def _get_folder_id(self, path=''): + location = self._location + + folder_id = self._folder_ids.get(location) + + self.debug('_get_folder_id(%s, %s, %s)' % (path, location, folder_id)) + + if not folder_id: + self.debug('finding album with title "%s"' % location) + folder_id = self._get_folder_id_by_name(location) + + self._folder_ids[location] = folder_id + + return folder_id + + def _get_folder_id_by_name(self, name, create=True): + try: + albums = self._get_albums() + albumsWithName = self._filter_albums(albums, name) + + if albumsWithName: + count = len(albumsWithName) + if count > 0: + albumId = albumsWithName[0].get('id') + self.debug('found %s existing album(s) "%s" taking first id "%s"' % (count, name, albumId)) + return albumId + + # create album + response = self._create_folder(None, name) + albumId = response.get('id') + self.info('Album "%s" was created successfully with id "%s"' % (name, albumId)) + return albumId + + except Exception as e: + self.error("_get_folder_id_by_name() failed: %s" % e) + raise + + def _create_folder(self, parent_id, child_name): + metadata = { + 'album': { + 'title': child_name + } + } + + body = json.dumps(metadata) + + headers = { + 'Content-Type': 'application/json' + } + + response = self._request_json(self.GOOGLE_PHOTO_API + 'albums', body, headers) + return response + + def _create_media(self, uploadToken, camera_name): + description = 'captured by motionEye camera' + (' "%s"' % camera_name if camera_name else '') + + metadata = { + 'albumId': self._get_folder_id(), + 'newMediaItems': [ + { + 'description': description, + 'simpleMediaItem': { + 'uploadToken': uploadToken + } + } + ] + } + + body = json.dumps(metadata) + + headers = { + 'Content-Type': 'application/json' + } + + response = self._request_json(self.GOOGLE_PHOTO_API + 'mediaItems:batchCreate', body, headers) + return response.get('newMediaItemResults')[0] + + def _get_albums(self): + response = self._request_json(self.GOOGLE_PHOTO_API + 'albums') + + albums = response.get('albums') + self.debug('got %s album(s)' % len(albums)) + return albums + + def _filter_albums(self, albums, title): + return [a for a in albums if a.get('title') == title] + + class Dropbox(UploadService): NAME = 'dropbox' @@ -549,7 +710,7 @@ def test_access(self): return msg - def upload_data(self, filename, mime_type, data): + def upload_data(self, filename, mime_type, data, ctime, camera_name): metadata = { 'path': os.path.join(self._clean_location(), filename), 'mode': 'add', @@ -707,7 +868,7 @@ def test_access(self): return str(e) - def upload_data(self, filename, mime_type, data): + def upload_data(self, filename, mime_type, data, ctime, camera_name): path = os.path.dirname(filename) filename = os.path.basename(filename) @@ -824,7 +985,7 @@ def test_access(self): return str(e) - def upload_data(self, filename, mime_type, data): + def upload_data(self, filename, mime_type, data, ctime, camera_name): conn = self._get_conn(filename) conn.setopt(pycurl.READFUNCTION, StringIO.StringIO(data).read) @@ -903,8 +1064,10 @@ def get(camera_id, service_name): camera_id = str(camera_id) service = _services.get(camera_id, {}).get(service_name) + if service is None: cls = UploadService.get_service_classes().get(service_name) + if cls: service = cls(camera_id=camera_id) _services.setdefault(camera_id, {})[service_name] = service @@ -931,13 +1094,13 @@ def update(camera_id, service_name, settings): service.save() -def upload_media_file(camera_id, target_dir, service_name, filename): +def upload_media_file(camera_id, camera_name, target_dir, service_name, filename): service = get(camera_id, service_name) if not service: return logging.error('service "%s" not initialized for camera with id %s' % (service_name, camera_id)) try: - service.upload_file(target_dir, filename) + service.upload_file(target_dir, filename, camera_name) except Exception as e: logging.error('failed to upload file "%s" with service %s: %s' % (filename, service, e), exc_info=True) @@ -974,6 +1137,7 @@ def _load(): for name, state in d.iteritems(): camera_services = services.setdefault(camera_id, {}) cls = UploadService.get_service_classes().get(name) + if cls: service = cls(camera_id=camera_id) service.load(state) @@ -1015,7 +1179,8 @@ def _save(services): def clean_cloud(local_dir, data, info): camera_id = info['camera_id'] service_name = info['service_name'] - cloud_dir = info['cloud_dir'] + cloud_dir_user = info['cloud_dir'] + cloud_dir = [p.strip() for p in cloud_dir_user.split('/') if p.strip()][0] logging.debug('clean_cloud(%s, %s, %s, %s)' % (camera_id, service_name, local_dir, cloud_dir)) diff --git a/motioneye/wifictl.py b/motioneye/wifictl.py index e5b035c2a..0191031af 100644 --- a/motioneye/wifictl.py +++ b/motioneye/wifictl.py @@ -209,8 +209,7 @@ def _set_wifi_settings(s): def network(): return { 'label': 'Network', - 'description': 'configure the network connection', - 'advanced': True + 'description': 'configure the network connection' } @@ -224,7 +223,6 @@ def wifiEnabled(): '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, @@ -242,7 +240,6 @@ def wifiNetworkName(): 'description': 'the name (SSID) of your wireless network', 'type': 'str', 'section': 'network', - 'advanced': True, 'required': True, 'reboot': True, 'depends': ['wifiEnabled'], @@ -262,7 +259,6 @@ def wifiNetworkKey(): 'description': 'the key (PSK) required to connect to your wireless network', 'type': 'pwd', 'section': 'network', - 'advanced': True, 'required': False, 'reboot': True, 'depends': ['wifiEnabled'],
Send An Email ?
Email Addresses ?
SMTP Server ?
SMTP Port ?
SMTP Account ?
SMTP Password ?
From Address ?
Use TLS ?
Attached Pictures Time Span seconds ?
Test Email
?
Call A Web Hook ?
Web Hook URL ?
HTTP Method ?
Run A Command ?
Command ?
Run An End Command ?
Command ?