From 80dd536e6307228397eaff4753404906be6cced2 Mon Sep 17 00:00:00 2001
From: Calin Crisan <ccrisan@gmail.com>
Date: Sun, 6 Oct 2013 12:45:05 +0300
Subject: [PATCH] the motion remote control is now working

---
 doc/todo.txt      |  38 ++++----
 src/config.py     |   1 -
 src/handlers.py   | 214 +++++++++++++++++++++++++++++++---------------
 src/remote.py     |  47 ++++++++++
 static/js/main.js |  97 ++++++++-------------
 5 files changed, 250 insertions(+), 147 deletions(-)

diff --git a/doc/todo.txt b/doc/todo.txt
index fd41383..5a092c5 100644
--- a/doc/todo.txt
+++ b/doc/todo.txt
@@ -1,29 +1,33 @@
--> move/copy list available resolutions code to get_config
--> bug: adding a remote device does not provide the available resolutions
- 
--> bug: if updating a remote camera config, local motion will get restarted
--> bug: when renaming the camera, the left/right texts set "Camera" should also change
--> make camera frames positions configurable
+-> add a motion running status indicator (and maybe a start/stop button)
+-> add a timeout checker to check the running status of motion
+
+-> better ajax error handling
+-> add a messaging mechanism
+
+-> authentication
+
 -> hide horrible 404 image on cameras
--> prevent Request closed errors by stopping mjpg clients before stopping motion
 -> camera not available background and icon design
+
+-> prevent Request closed errors by stopping mjpg clients before stopping motion
+-> make all the server http requests async
 -> remove current snapshot GET logs
--> add a motion running status indicator (and maybe a start/stop button)
--> add a timeout checker to check the running status of motion
--> group @config rules to top
--> browser compatibility test
--> requirements test
+
 -> style scroll bars
 -> hint text next to section titles
 -> clickable hints
--> authentication
--> better ajax error handling
--> proxy for slave motioneyes
+
+-> make camera frames positions configurable
 -> add a view log functionality
 -> click to zoom on cameras
 -> add a previewer for movies
 -> add a previewer for snapshots
 -> add a motioneye.svg icon
+
+-> add other options applicable only to special devices (rpi): wifi settings, notifications
+-> group @config rules to top
+
+-> browser compatibility test
+-> requirements test
+
 -> other todos
--> add a messaging mechanism
--> add other options applicable only to special devices (rpi): wifi settings, notifications
\ No newline at end of file
diff --git a/src/config.py b/src/config.py
index 3dae27d..7c7306a 100644
--- a/src/config.py
+++ b/src/config.py
@@ -207,7 +207,6 @@ def set_camera(camera_id, data):
     
         main_config['thread'] = threads
         
-        del data['@enabled']
         if '@id' in data:
             del data['@id']
         
diff --git a/src/handlers.py b/src/handlers.py
index b81e84d..8cccbc9 100644
--- a/src/handlers.py
+++ b/src/handlers.py
@@ -104,8 +104,7 @@ class ConfigHandler(BaseHandler):
 
                 local_data = camera_config        
                 camera_config = self._camera_ui_to_dict(remote_ui_config)
-                camera_config['@proto'] = local_data['@proto']
-                camera_config['@enabled'] = local_data['@enabled']
+                camera_config.update(local_data)
                 
             ui_config = self._camera_dict_to_ui(camera_config)
             
@@ -125,16 +124,29 @@ class ConfigHandler(BaseHandler):
             ui_config = self._main_dict_to_ui(config.get_main())
             self.finish_json(ui_config)
     
-    def set_config(self, camera_id):
-        try:
-            ui_config = json.loads(self.request.body)
-            
-        except Exception as e:
-            logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)})
-            
-            raise
+    def set_config(self, camera_id, ui_config=None):
+        if ui_config is None:
+            try:
+                ui_config = json.loads(self.request.body)
+                
+            except Exception as e:
+                logging.error('could not decode json: %(msg)s' % {'msg': unicode(e)})
+                
+                raise
         
-        if camera_id:
+        if camera_id is not None:
+            if camera_id == 0:
+                logging.debug('setting multiple configs')
+                
+                for key, cfg in ui_config.items():
+                    if key == 'main':
+                        self.set_config(None, cfg)
+                        
+                    else:
+                        self.set_config(int(key), cfg)
+
+                return
+                 
             logging.debug('setting config for camera %(id)s' % {'id': camera_id})
             
             camera_ids = config.get_camera_ids()
@@ -143,18 +155,34 @@ class ConfigHandler(BaseHandler):
             
             camera_config = config.get_camera(camera_id)
             if camera_config['@proto'] == 'v4l2':
+                ui_config.setdefault('device', camera_config.get('videodevice', ''))
+                ui_config.setdefault('proto', camera_config['@proto'])
+                ui_config.setdefault('enabled', camera_config['@enabled'])
+                
                 camera_config = self._camera_ui_to_dict(ui_config)
-                camera_config['@proto'] = 'v4l2'
                 config.set_camera(camera_id, camera_config)
                 
-            else:
-                remote.set_config(
-                        camera_config.get('@host'),
-                        camera_config.get('@port'),
-                        camera_config.get('@username'),
-                        camera_config.get('@password'),
-                        camera_config.get('@remote_camera_id'),
-                        ui_config)
+            else:  # remote camera
+                # update the camera locally
+                camera_config['@enabled'] = ui_config['enabled']
+                config.set_camera(camera_id, camera_config)
+                
+                # remove the fields that should not get to the remote side
+                del ui_config['device']
+                del ui_config['proto']
+                del ui_config['enabled']
+                
+                try:
+                    remote.set_config(
+                            camera_config.get('@host'),
+                            camera_config.get('@port'),
+                            camera_config.get('@username'),
+                            camera_config.get('@password'),
+                            camera_config.get('@remote_camera_id'),
+                            ui_config)
+                    
+                except Exception as e:
+                    return self.finish_json({'error': unicode(e)})       
 
         else:
             logging.debug('setting main config')
@@ -162,8 +190,7 @@ class ConfigHandler(BaseHandler):
             main_config = self._main_ui_to_dict(ui_config)
             config.set_main(main_config)
 
-        if not ui_config.get('norestart'):
-            motionctl.restart()
+        motionctl.restart()
 
     def set_preview(self, camera_id):
         try:
@@ -175,31 +202,45 @@ class ConfigHandler(BaseHandler):
             raise
 
         camera_config = config.get_camera(camera_id)
-        device = camera_config['videodevice']
-        
-        if 'brightness' in controls:
-            value = int(controls['brightness'])
-            logging.debug('setting brightness to %(value)s...' % {'value': value})
-
-            v4l2ctl.set_brightness(device, value)
-
-        if 'contrast' in controls:
-            value = int(controls['contrast'])
-            logging.debug('setting contrast to %(value)s...' % {'value': value})
-
-            v4l2ctl.set_contrast(device, value)
-
-        if 'saturation' in controls:
-            value = int(controls['saturation'])
-            logging.debug('setting saturation to %(value)s...' % {'value': value})
-
-            v4l2ctl.set_saturation(device, value)
-
-        if 'hue' in controls:
-            value = int(controls['hue'])
-            logging.debug('setting hue to %(value)s...' % {'value': value})
+        if camera_config['@proto'] == 'v4l2': 
+            device = camera_config['videodevice']
+            
+            if 'brightness' in controls:
+                value = int(controls['brightness'])
+                logging.debug('setting brightness to %(value)s...' % {'value': value})
+    
+                v4l2ctl.set_brightness(device, value)
+    
+            if 'contrast' in controls:
+                value = int(controls['contrast'])
+                logging.debug('setting contrast to %(value)s...' % {'value': value})
+    
+                v4l2ctl.set_contrast(device, value)
+    
+            if 'saturation' in controls:
+                value = int(controls['saturation'])
+                logging.debug('setting saturation to %(value)s...' % {'value': value})
+    
+                v4l2ctl.set_saturation(device, value)
+    
+            if 'hue' in controls:
+                value = int(controls['hue'])
+                logging.debug('setting hue to %(value)s...' % {'value': value})
+    
+                v4l2ctl.set_hue(device, value)
 
-            v4l2ctl.set_hue(device, value)
+        else:
+            try:
+                remote.set_preview(
+                        camera_config['@host'],
+                        camera_config['@port'],
+                        camera_config['@username'],
+                        camera_config['@password'],
+                        camera_config['@remote_camera_id'],
+                        controls)
+                
+            except Exception as e:
+                self.finish_json({'error': unicode(e)})
 
     def list_cameras(self):
         logging.debug('listing cameras')
@@ -209,7 +250,7 @@ class ConfigHandler(BaseHandler):
         username = self.get_argument('username', None)
         password = self.get_argument('password', None)
         
-        if host: # remote listing
+        if host:  # remote listing
             try:
                 cameras = remote.list_cameras(host, port, username, password)
                 
@@ -221,11 +262,11 @@ class ConfigHandler(BaseHandler):
             for camera_id in config.get_camera_ids():
                 camera_config = config.get_camera(camera_id)
                 if camera_config['@proto'] == 'v4l2':
-                    name = camera_config['@name']
-                    
-                else: # remote camera
+                    ui_config = self._camera_dict_to_ui(camera_config)
+
+                else:  # remote camera
                     try:
-                        remote_camera_config = remote.get_config(
+                        remote_ui_config = remote.get_config(
                                 camera_config.get('@host'),
                                 camera_config.get('@port'),
                                 camera_config.get('@username'),
@@ -235,9 +276,11 @@ class ConfigHandler(BaseHandler):
                     except:
                         continue
                     
-                    name = remote_camera_config['name']
+                    ui_config = remote_ui_config
+                    ui_config['id'] = camera_id
+                    ui_config['enabled'] = camera_config['@enabled']  # override the enabled status
 
-                cameras.append({'name': name, 'id': camera_id})
+                cameras.append(ui_config)
 
         self.finish_json({'cameras': cameras})
     
@@ -247,8 +290,9 @@ class ConfigHandler(BaseHandler):
         configured_devices = {}
         for camera_id in config.get_camera_ids():
             data = config.get_camera(camera_id)
-            configured_devices[data['videodevice']] = True
-            
+            if data['@proto'] == 'v4l2':
+                configured_devices[data['videodevice']] = True
+
         devices = [{'device': d[0], 'name': d[1], 'configured': d[0] in configured_devices}
                 for d in v4l2ctl.list_devices()]
         
@@ -293,8 +337,7 @@ class ConfigHandler(BaseHandler):
 
             local_data = camera_config
             camera_config = self._camera_ui_to_dict(remote_ui_config)
-            camera_config['@enabled'] = local_data['@enabled']
-            camera_config['@proto'] = local_data['@proto']
+            camera_config.update(local_data)
         
         camera_config['@id'] = camera_id
         
@@ -340,14 +383,16 @@ class ConfigHandler(BaseHandler):
         }
 
     def _camera_ui_to_dict(self, ui):
-        if not ui.get('resolution'): # avoid errors for empty resolution setting
+        if not ui.get('resolution'):  # avoid errors for empty resolution setting
             ui['resolution'] = '352x288'
     
         data = {
             # device
             '@name': ui.get('name', ''),
             '@enabled': ui.get('enabled', False),
-            'lightswitch': int(ui.get('light_switch_detect', False) * 5),
+            '@proto': ui.get('proto', 'v4l2'),
+            'videodevice': ui.get('device', ''),
+            'lightswitch': int(ui.get('light_switch_detect', False)) * 5,
             'auto_brightness': ui.get('auto_brightness', False),
             'brightness': max(1, int(round(int(ui.get('brightness', 0)) * 2.55))),
             'contrast': max(1, int(round(int(ui.get('contrast', 0)) * 2.55))),
@@ -457,23 +502,35 @@ class ConfigHandler(BaseHandler):
             
         if ui.get('working_schedule', False):
             data['@working_schedule'] = (
-                    ui.get('monday_from', '') + '-' + ui.get('monday_to') + '|' +
-                    ui.get('tuesday_from', '') + '-' + ui.get('tuesday_to') + '|' +
-                    ui.get('wednesday_from', '') + '-' + ui.get('wednesday_to') + '|' +
-                    ui.get('thursday_from', '') + '-' + ui.get('thursday_to') + '|' +
-                    ui.get('friday_from', '') + '-' + ui.get('friday_to') + '|' +
-                    ui.get('saturday_from', '') + '-' + ui.get('saturday_to') + '|' +
+                    ui.get('monday_from', '') + '-' + ui.get('monday_to') + '|' + 
+                    ui.get('tuesday_from', '') + '-' + ui.get('tuesday_to') + '|' + 
+                    ui.get('wednesday_from', '') + '-' + ui.get('wednesday_to') + '|' + 
+                    ui.get('thursday_from', '') + '-' + ui.get('thursday_to') + '|' + 
+                    ui.get('friday_from', '') + '-' + ui.get('friday_to') + '|' + 
+                    ui.get('saturday_from', '') + '-' + ui.get('saturday_to') + '|' + 
                     ui.get('sunday_from', '') + '-' + ui.get('sunday_to'))
     
         return data
         
     def _camera_dict_to_ui(self, data):
+        if data['@proto'] == 'v4l2':
+            device_uri = data['videodevice']
+        
+        else:
+            device_uri = '%(host)s:%(port)s/config/%(camera_id)s' % {
+                    'username': data['@username'],
+                    'password': '***',
+                    'host': data['@host'],
+                    'port': data['@port'],
+                    'camera_id': data['@remote_camera_id']}
+        
         ui = {
             # device
             'name': data['@name'],
             'enabled': data['@enabled'],
             'id': data.get('@id'),
             'proto': data['@proto'],
+            'device': device_uri,
             'light_switch_detect': data.get('lightswitch') > 0,
             'auto_brightness': data.get('auto_brightness'),
             'brightness': int(round(int(data.get('brightness')) / 2.55)),
@@ -630,12 +687,29 @@ class SnapshotHandler(BaseHandler):
             raise HTTPError(400, 'unknown operation')
     
     def current(self, camera_id):
-        jpg = mjpgclient.get_jpg(camera_id)
-        if jpg is None:
-            return self.finish()
+        camera_config = config.get_camera(camera_id)
+        if camera_config['@proto'] == 'v4l2':
+            jpg = mjpgclient.get_jpg(camera_id)
+            if jpg is None:
+                return self.finish()
         
-        self.set_header('Content-Type', 'image/jpeg')
-        self.finish(jpg)
+            self.set_header('Content-Type', 'image/jpeg')
+            self.finish(jpg)
+        
+        else:
+            try:
+                jpg = remote.current_snapshot(
+                        camera_config['@host'],
+                        camera_config['@port'],
+                        camera_config['@username'],
+                        camera_config['@password'],
+                        camera_config['@remote_camera_id'])
+                
+            except:
+                return self.finish()       
+
+            self.set_header('Content-Type', 'image/jpeg')
+            self.finish(jpg)
     
     def list(self, camera_id):
         logging.debug('listing snapshots for camera %(id)s' % {'id': camera_id})
diff --git a/src/remote.py b/src/remote.py
index a3ec679..a753bc0 100644
--- a/src/remote.py
+++ b/src/remote.py
@@ -108,3 +108,50 @@ def set_config(host, port, username, password, camera_id, data):
         
         raise
 
+
+def set_preview(host, port, username, password, camera_id, controls):
+    logging.debug('setting preview for remote camera %(id)s on %(host)s:%(port)s' % {
+            'id': camera_id,
+            'host': host,
+            'port': port})
+    
+    controls = json.dumps(controls)
+    
+    url = _compose_url(host, port, username, password, '/config/%(id)s/set_preview/' % {'id': camera_id})
+    request = urllib2.Request(url, data=controls)
+    
+    try:
+        urllib2.urlopen(request)
+    
+    except Exception as e:
+        logging.error('failed to set preview for remote camera %(id)s on %(host)s:%(port)s: %(msg)s' % {
+                'id': camera_id,
+                'host': host,
+                'port': port,
+                'msg': unicode(e)})
+        
+        raise
+
+
+def current_snapshot(host, port, username, password, camera_id):
+    logging.debug('getting current snapshot for remote camera %(id)s on %(host)s:%(port)s' % {
+            'id': camera_id,
+            'host': host,
+            'port': port})
+    
+    url = _compose_url(host, port, username, password, '/snapshot/%(id)s/current/' % {'id': camera_id})
+    request = urllib2.Request(url)
+    
+    try:
+        response = urllib2.urlopen(request)
+    
+    except Exception as e:
+        logging.error('failed to get current snapshot for remote camera %(id)s on %(host)s:%(port)s: %(msg)s' % {
+                'id': camera_id,
+                'host': host,
+                'port': port,
+                'msg': unicode(e)})
+        
+        raise
+    
+    return response.read()
diff --git a/static/js/main.js b/static/js/main.js
index b4856f7..647db28 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -255,27 +255,7 @@ function initUI() {
     $(window).resize(updateModalDialogPosition);
     
     /* remove camera button */
-    $('div.button.rem-camera-button').click(function () {
-        var cameraId = $('#videoDeviceSelect').val();
-        if (cameraId == null || cameraId === 'add') {
-            runAlertDialog('No camera to remove!');
-            return;
-        }
-
-        var deviceName = $('#videoDeviceSelect').find('option[value=' + cameraId + ']').text();
-        
-        runConfirmDialog('Remove device ' + deviceName + '?', function () {
-            showProgress();
-            ajax('POST', '/config/' + cameraId + '/rem/', null, function (data) {
-                if (data == null || data.error) {
-                    return; // TODO handle error
-                }
-                
-                hideApply();
-                fetchCurrentConfig();
-            });
-        });
-    });
+    $('div.button.rem-camera-button').click(doRemCamera);
 }
 
 
@@ -472,7 +452,8 @@ function cameraUi2Dict() {
         /* video device */
         'enabled': $('#videoDeviceSwitch')[0].checked,
         'name': $('#deviceNameEntry').val(),
-        'device': $('#deviceEntry').val(),
+        'proto': $('#deviceEntry').val().split('://')[0],
+        'device': $('#deviceEntry').val().split('://')[1],
         'light_switch_detect': $('#lightSwitchDetectSwitch')[0].checked,
         'auto_brightness': $('#autoBrightnessSwitch')[0].checked,
         'brightness': $('#brightnessSlider').val(),
@@ -555,7 +536,7 @@ function dict2CameraUi(dict) {
     /* video device */
     $('#videoDeviceSwitch')[0].checked = dict['enabled'];
     $('#deviceNameEntry').val(dict['name']);
-    $('#deviceEntry').val(dict['device']);
+    $('#deviceEntry').val(dict['proto'] + '://' + dict['device']);
     $('#lightSwitchDetectSwitch')[0].checked = dict['light_switch_detect'];
     $('#autoBrightnessSwitch')[0].checked = dict['auto_brightness'];
     $('#brightnessSlider').val(dict['brightness']);
@@ -715,26 +696,6 @@ function isApplyVisible() {
 }
 
 function doApply() {
-    var finishedCount = 0;
-    var configs = [];
-    
-    function testReady() {
-        if (finishedCount >= configs.length) {
-            endProgress();
-            recreateCameraFrames();
-        }
-    }
-    
-    for (var key in pushConfigs) {
-        if (pushConfigs.hasOwnProperty(key)) {
-            configs.push({key: key, config: pushConfigs[key]});
-        }
-    }
-    
-    if (configs.length === 0) {
-        return;
-    }
-    
     if (!configUiValid()) {
         runAlertDialog('Make sure all the configuration options are valid!');
         
@@ -743,27 +704,46 @@ function doApply() {
     
     showProgress();
     
-    for (var i = 0; i < configs.length; i++) {
-        var config = configs[i];
-        if (i === configs.length - 1) {
-            config.config['last'] = true; // TODO not used, to be replaced by norestart
+    ajax('POST', '/config/0/set/', pushConfigs, function (data) {
+        if (data == null || data.error) {
+            return; // TODO handle error
         }
-        ajax('POST', '/config/' + config.key + '/set/', config.config, function (data) {
-            if (data == null || data.error) {
-                return; // TODO handle error
+        
+        /* update the camera name in the device select */
+        Object.keys(pushConfigs).forEach(function (key) {
+            var config = pushConfigs[key];
+            if (config.key !== 'main') {
+                $('#videoDeviceSelect').find('option[value=' + key + ']').html(config.name);
             }
-            
-            finishedCount++;
-            testReady();
         });
+
+        pushConfigs = {};
+        endProgress();
         
-        /* update the camera name in the device select */
-        if (config.key !== 'main') {
-            $('#videoDeviceSelect').find('option[value=' + config.key + ']').html(config.config.name);
-        }
+        recreateCameraFrames();
+    });
+}
+
+function doRemCamera() {
+    var cameraId = $('#videoDeviceSelect').val();
+    if (cameraId == null || cameraId === 'add') {
+        runAlertDialog('No camera to remove!');
+        return;
     }
+
+    var deviceName = $('#videoDeviceSelect').find('option[value=' + cameraId + ']').text();
     
-    pushConfigs = {};
+    runConfirmDialog('Remove device ' + deviceName + '?', function () {
+        showProgress();
+        ajax('POST', '/config/' + cameraId + '/rem/', null, function (data) {
+            if (data == null || data.error) {
+                return; // TODO handle error
+            }
+            
+            hideApply();
+            fetchCurrentConfig();
+        });
+    });
 }
 
 
@@ -1218,7 +1198,6 @@ function doCloseCamera(cameraId) {
         }
         
         data['enabled'] = false;
-        data['last'] = true;// TODO not used, to be replaced by norestart
         ajax('POST', '/config/' + cameraId + '/set/', data, function (data) {
             if (data == null || data.error) {
                 return; // TODO handle error
-- 
2.39.5