Browse Source

Merge branch 'rest_api' into dev

monf 8 years ago
parent
commit
c3c16e3f4e

+ 35 - 0
Docs/settings.md

@@ -163,3 +163,38 @@ random_wake_up_sounds:
 
 >**Note: ** If you want to use a wake up sound instead of a wake up answer you must comment out the `random_wake_up_answers` section.
 E.g: `# random_wake_up_answers:`
+
+
+## Rest API
+
+A Rest API can be activated in order to:
+- List synapses
+- Get synapse's detail
+- Run a synapse
+
+For the complete API ref see the [signals documentation](signals.md)
+
+Settings examples:
+```
+rest_api:
+  active: True
+  port: 5000
+  password_protected: True
+  login: admin
+  password: secret
+```
+
+#### active
+To enable the rest api server.
+
+#### port
+The listening port of the web server. Must be an integer in range 1024-65535.
+
+#### password_protected
+If `True`, the whole api will be password protected.
+
+#### Login
+Login used by the basic HTTP authentication. Must be provided if `password_protected` is `True`
+
+#### Password
+Password used by the basic HTTP authentication. Must be provided if `password_protected` is `True`

+ 135 - 1
Docs/signals.md

@@ -98,4 +98,138 @@ Synapse "wake up" added to the crontab
 Event loaded in crontab
 ```
 
-That's it, the synapse is now scheduled and will be started automatically.
+That's it, the synapse is now scheduled and will be started automatically.
+
+## Rest API
+
+Each synapse can be started from the REST API. For configuring the API see the [settings documentation](settings.md).
+
+### Synapse API
+
+| Method | URL                      | Action               |
+|--------|--------------------------|----------------------|
+| GET    | /synapses                | List synapses        |
+| GET    | /synapses/<synapse_name> | Show synapse details |
+| POST   | /synapses/<synapse_name> | Run a synapse        |
+
+### Curl examples
+
+>**Note:** --user is only needed if `password_protected` is True
+
+#### Get all synapse
+
+Normal response codes: 200
+Error response codes: unauthorized(401), itemNotFound(404)
+Curl command:
+```
+curl -i --user admin:secret -X GET  http://localhost:5000/synapses/
+```
+
+Output example:
+```
+{
+  "synapses": [
+    [
+      {
+        "name": "stop-kalliope",
+        "neurons": [
+          {
+            "say": {
+              "message": "Good bye"
+            }
+          },
+          "kill_switch"
+        ],
+        "signals": [
+          {
+            "order": "close"
+          }
+        ]
+      }
+    ],
+    [
+      {
+        "name": "say-hello",
+        "neurons": [
+          {
+            "say": {
+              "message": [
+                "Bonjour monsieur"
+              ]
+            }
+          }
+        ],
+        "signals": [
+          {
+            "order": "bonjour"
+          }
+        ]
+      }
+    ]
+  ]
+}
+```
+
+#### Get synapse's detail. 
+
+Normal response codes: 200
+Error response codes: unauthorized(401), itemNotFound(404)
+Curl command:
+```
+curl -i --user admin:secret -X GET  http://localhost:5000/synapses/say-hello
+```
+
+Output example:
+```
+{
+  "synapses": {
+    "name": "say-hello",
+    "neurons": [
+      {
+        "say": {
+          "message": [
+            "Bonjour monsieur"
+          ]
+        }
+      }
+    ],
+    "signals": [
+      {
+        "order": "bonjour"
+      }
+    ]
+  }
+}
+```
+
+#### Run a synapse by its name. 
+
+Normal response codes: 201
+Error response codes: unauthorized(401), itemNotFound(404)
+Curl command:
+```
+curl -i --user admin:secret -X POST  http://localhost:5000/synapses/say-hello
+```
+
+Output example:
+```
+{
+  "synapses": {
+    "name": "say-hello",
+    "neurons": [
+      {
+        "say": {
+          "message": [
+            "Bonjour monsieur"
+          ]
+        }
+      }
+    ],
+    "signals": [
+      {
+        "order": "bonjour"
+      }
+    ]
+  }
+}
+```

+ 13 - 6
README.md

@@ -30,12 +30,19 @@ Kalliope is easy-peasy to use, see the hello world
 - [Create the brain of your Kalliope](Docs/brain.md)
 - [Run Kalliope with CLI](Docs/kalliope_cli.md)
 
-## Neurons
-
-A neuron is a plugin that can be used from your **brain.yml**. 
-
-- See the list of [available neurons](Docs/neuron_list.md).
-- See how to [create your own neuron](Docs/contributing.md).
+## Documentation summary
+
+| Link                               | Detail                                                                                      |
+|------------------------------------|---------------------------------------------------------------------------------------------|
+| [Settings](Docs/settings.md)       | The main Kalliope configuration                                                             |
+| [Brain](Docs/brain.md)             | What is the brain and how to create your own bot                                            |
+| [neuron](Docs/neurons.md)          | What is a neuron and how to use it                                                          |
+| [neuron list](Docs/neuron_list.md) | List of availlable neurons                                                                  |
+| [CLI](Docs/kalliope_cli.md)        | How to use Kalliope from the command line interface                                         |
+| [Signals](Docs/signals.md)         | Signals are input event that can wake up kalliope (spoken order, scheduled event, REST API) |
+| [STT](Docs/stt.md)                 | Speech to text configuration                                                                |
+| [TTS](Docs/tts.md)                 | Text to speech configuration                                                                |
+| [Triggers](Docs/tts.md)            | Magic hotword engine used to make Kalliope listtening for an order                          |
 
 
 ## Contributing

+ 1 - 1
brains/ansible_playbook.yml

@@ -1,6 +1,6 @@
 ---
 
-  - name: "Ansible test"
+  - name: "Ansible-test"
     neurons:
       - ansible_playbook: "tasks.yml"
       - say:

+ 1 - 1
brains/gmail_checker.yml

@@ -1,6 +1,6 @@
 ---
 
-  - name: "check email"
+  - name: "check-email"
     neurons:
       - gmail_checker:
           username: "me@gmail.com"

+ 1 - 1
brains/kill_switch.yml

@@ -1,6 +1,6 @@
 ---
 
-  - name: "Stop kalliope"
+  - name: "stop-kalliope"
     neurons:
       - say:
           message: "Aurevoir"

+ 1 - 1
brains/openweathermap.yml

@@ -1,5 +1,5 @@
 ---
-  - name: "get the weather"
+  - name: "get-the-weather"
     neurons:
       - openweathermap:
           api_key: "your-api"

+ 1 - 1
brains/push_message.yml

@@ -1,5 +1,5 @@
 ---
-  - name: "Send push message"
+  - name: "send-push-message"
     neurons:
       - push_message:
            message: "Message to send"

+ 1 - 1
brains/say.yml

@@ -1,5 +1,5 @@
 ---
-  - name: "say hello"
+  - name: "say-hello"
     neurons:
       - say:
           message:

+ 1 - 1
brains/script.yml

@@ -1,5 +1,5 @@
 ---
-  - name: "Run a simple script"
+  - name: "run-simple-script"
     neurons:
       - script:
           path: "/home/nico/test.sh"

+ 6 - 6
brains/shell.yml

@@ -1,6 +1,6 @@
 ---
 
-  - name: "Close rolling shutter"
+  - name: "close-rolling-shutter"
     neurons:
       - shell:
           cmd: "curl http://192.168.0.22:5000/fermeture -d \"password=monpass\" -X POST"
@@ -9,7 +9,7 @@
     signals:
       - order: "ferme les volets"
 
-  - name: "Open rolling shutter"
+  - name: "open-rolling-shutter"
     neurons:
       - shell:
           cmd: "curl http://192.168.0.22:5000/ouverture -d \"password=monpass\" -X POST"
@@ -19,7 +19,7 @@
       - order: "ouvre les volets"
 
 
-  - name: "Start steam"
+  - name: "start-steam"
     neurons:
       - shell:
           cmd: "curl -i --user admin:secret -H \"Content-Type: application/json\" -X POST -d '{\"app_name\":\"kodi\",\"state\":\"stop\"}' http://192.168.0.17:8000/app"
@@ -30,7 +30,7 @@
     signals:
       - order: "lance Steam"
 
-  - name: "Start Kodi"
+  - name: "start-Kodi"
     neurons:
       - shell:
           cmd: "curl -i --user admin:secret -H \"Content-Type: application/json\" -X POST -d '{\"app_name\":\"steam\",\"state\":\"stop\"}' http://192.168.0.17:8000/app"
@@ -41,7 +41,7 @@
     signals:
       - order: "lance Cody"
 
-  - name: "Start music"
+  - name: "start-music"
     neurons:
       - shell:
           cmd: "curl -i --user admin:secret -H \"Content-Type: application/json\" -X POST -d '{\"app_name\":\"music\",\"state\":\"start\"}' http://192.168.0.17:8000/app"
@@ -51,7 +51,7 @@
       - order: "mais nous de la musique"
       - order: "musique rock"
 
-  - name: "Stop music"
+  - name: "stop-music"
     neurons:
       - shell:
           cmd: "curl -i --user admin:secret -H \"Content-Type: application/json\" -X POST -d '{\"app_name\":\"music\",\"state\":\"stop\"}' http://192.168.0.17:8000/app"

+ 2 - 2
brains/systemdate.yml

@@ -1,6 +1,6 @@
 ---
 
-  - name: "Say local date"
+  - name: "say-local-date"
     neurons:
       - systemdate:
           tts: "voxygen"
@@ -9,7 +9,7 @@
     signals:
       - order: "quelle heure est-il"
 
-  - name: "Say local date from template"
+  - name: "say-local-date-from-template"
     neurons:
       - systemdate:
           file_template: en_systemdate_template_example.j2

+ 1 - 1
brains/tasker_autoremote.yml

@@ -1,6 +1,6 @@
 ---
 
-  - name: "find my phone"
+  - name: "find-my-phone"
     neurons:
       - say:
           message: "Je fais sonner le téléphone, monsieur"

+ 1 - 1
brains/wake_on_lan.yml

@@ -1,6 +1,6 @@
 ---
 
-  - name: "wake my PC"
+  - name: "wake-my-PC"
     neurons:
       - wake_on_lan:
           mac_address: "00-00-00-00-00-00"

+ 13 - 3
core/ConfigurationManager/ConfigurationChecker.py

@@ -1,6 +1,10 @@
 import re
 
 
+class InvalidSynapeName(Exception):
+    pass
+
+
 class NoSynapeName(Exception):
     pass
 
@@ -44,6 +48,15 @@ class ConfigurationChecker:
         if 'name' not in synape_dict:
             raise NoSynapeName("The Synapse does not have a name: %s" % synape_dict)
 
+        # check that the name is conform
+        # Regex for [a - zA - Z0 - 9\-] with dashes allowed in between but not at the start or end
+        pattern = r'(?=[a-zA-Z0-9\-]{4,25}$)^[a-zA-Z0-9]+(\-[a-zA-Z0-9]+)*$'
+        prog = re.compile(pattern)
+        result = prog.match(synape_dict["name"])
+        if result is None:
+            raise InvalidSynapeName("Error with synapse name \"%s\".Valid syntax: [a - zA - Z0 - 9\-] with dashes "
+                                    "allowed in between but not at the start or end" % synape_dict["name"])
+
         if 'neurons' not in synape_dict:
             raise NoSynapeNeurons("The Synapse does not have neurons: %s" % synape_dict)
 
@@ -81,7 +94,6 @@ class ConfigurationChecker:
         """
         Check the synapse list is ok:
          - No double same name
-         - No accent of special character
         :param synapses_list:
         :type synapses_list: list of Synapse
         :return:
@@ -93,7 +105,5 @@ class ConfigurationChecker:
             if synapse_name in seen:
                 raise MultipleSameSynapseName("Multiple synapse found with the same name: %s" % synapse_name)
             seen.add(synapse.name)
-            if not re.match("^[a-zA-Z0-9_\s]*$", synapse.name):
-                raise NotValidSynapseName("Synapse's name %s not valid." % synapse_name)
 
         return True

+ 62 - 11
core/ConfigurationManager/SettingLoader.py

@@ -1,6 +1,7 @@
 from YAMLLoader import YAMLLoader
 import logging
 
+from core.Models.RestAPI import RestAPI
 from core.Models.Settings import Settings
 from core.Models.Stt import Stt
 from core.Models.Trigger import Trigger
@@ -12,6 +13,10 @@ logging.basicConfig()
 logger = logging.getLogger("kalliope")
 
 
+class SettingInvalidException(Exception):
+    pass
+
+
 class NullSettingException(Exception):
     pass
 
@@ -46,6 +51,7 @@ class SettingLoader(object):
         triggers = cls._get_triggers(settings)
         random_wake_up_answers = cls._get_random_wake_up_answers(settings)
         random_wake_up_sounds = cls._get_random_wake_up_sounds(settings)
+        rest_api = cls._get_rest_api(settings)
 
         # create a setting object
         setting_object = Settings(default_stt_name=default_stt_name,
@@ -55,7 +61,8 @@ class SettingLoader(object):
                                   ttss=ttss,
                                   triggers=triggers,
                                   random_wake_up_answers=random_wake_up_answers,
-                                  random_wake_up_sounds=random_wake_up_sounds)
+                                  random_wake_up_sounds=random_wake_up_sounds,
+                                  rest_api=rest_api)
         return setting_object
 
     @staticmethod
@@ -67,8 +74,8 @@ class SettingLoader(object):
                 raise NullSettingException("Attribute default_speech_to_text is null")
             logger.debug("Default STT: %s" % default_speech_to_text)
             return default_speech_to_text
-        except KeyError:
-            raise SettingNotFound("Attribute default_speech_to_text not found in settings")
+        except KeyError, e:
+            raise SettingNotFound("%s setting not found" % e)
 
     @staticmethod
     def _get_default_text_to_speech(settings):
@@ -78,8 +85,8 @@ class SettingLoader(object):
                 raise NullSettingException("Attribute default_text_to_speech is null")
             logger.debug("Default TTS: %s" % default_text_to_speech)
             return default_text_to_speech
-        except KeyError:
-            raise SettingNotFound("Attribute default_text_to_speech not found in settings")
+        except KeyError, e:
+            raise SettingNotFound("%s setting not found" % e)
 
     @staticmethod
     def _get_default_trigger(settings):
@@ -89,8 +96,8 @@ class SettingLoader(object):
                 raise NullSettingException("Attribute default_trigger is null")
             logger.debug("Default Trigger name: %s" % default_trigger)
             return default_trigger
-        except KeyError:
-            raise SettingNotFound("Attribute default_trigger not found in settings")
+        except KeyError, e:
+            raise SettingNotFound("%s setting not found" % e)
 
     @classmethod
     def _get_stts(cls, settings):
@@ -128,8 +135,8 @@ class SettingLoader(object):
         """
         try:
             text_to_speech_list = settings["text_to_speech"]
-        except KeyError:
-            raise SettingNotFound("text_to_speech settings not found")
+        except KeyError, e:
+            raise SettingNotFound("%s setting not found" % e)
 
         ttss = list()
         for text_to_speech_el in text_to_speech_list:
@@ -155,8 +162,8 @@ class SettingLoader(object):
         """
         try:
             triggers_list = settings["triggers"]
-        except KeyError:
-            raise SettingNotFound("text_to_speech settings not found")
+        except KeyError, e:
+            raise SettingNotFound("%s setting not found" % e)
 
         triggers = list()
         for trigger_el in triggers_list:
@@ -210,3 +217,47 @@ class SettingLoader(object):
             raise NullSettingException("random_wake_up_sounds settings is empty")
 
         return random_wake_up_sounds_list
+
+    @classmethod
+    def _get_rest_api(cls, settings):
+        try:
+            rest_api = settings["rest_api"]
+        except KeyError, e:
+            raise SettingNotFound("%s setting not found" % e)
+
+        if rest_api is not None:
+            try:
+                password_protected = rest_api["password_protected"]
+                if password_protected is None:
+                    raise NullSettingException("password_protected setting cannot be null")
+                login = rest_api["login"]
+                password = rest_api["password"]
+                if password_protected:
+                    if login is None:
+                        raise NullSettingException("login setting cannot be null if password_protected is True")
+                    if login is None:
+                        raise NullSettingException("password setting cannot be null if password_protected is True")
+                active = rest_api["active"]
+                if active is None:
+                    raise NullSettingException("active setting cannot be null")
+                port = rest_api["port"]
+                if port is None:
+                    raise NullSettingException("port setting cannot be null")
+                # check that the port in an integer
+                try:
+                    port = int(port)
+                except ValueError:
+                    raise SettingInvalidException("port must be an integer")
+                # check the port is a valid port number
+                if not 1024 <= port <= 65535:
+                    raise SettingInvalidException("port must be in range 1024-65535")
+
+            except KeyError, e:
+                print e
+                raise SettingNotFound("%s settings not found" % e)
+
+            # config ok, we can return the rest api object
+            rest_api_obj = RestAPI(password_protected=password_protected, login=login, password=password, active=active, port=port)
+            return rest_api_obj
+        else:
+            raise NullSettingException("rest_api settings cannot be null")

+ 9 - 1
core/MainController.py

@@ -8,7 +8,8 @@ from core.ConfigurationManager import SettingLoader
 from core.OrderAnalyser import OrderAnalyser
 from core.OrderListener import OrderListener
 from core.TriggerLauncher import TriggerLauncher
-
+from flask import Flask
+from core.RestAPI.FlaskAPI import FlaskAPI
 from neurons import Say
 
 logging.basicConfig()
@@ -21,6 +22,13 @@ class MainController:
         # get global configuration
         self.settings = SettingLoader.get_settings()
 
+        # run the api if the user want it
+        if self.settings.rest_api.active:
+            Utils.print_info("Starting REST API Listening port: %s" % self.settings.rest_api.port)
+            app = Flask(__name__)
+            flask_api = FlaskAPI(app, port=self.settings.rest_api.port, brain_file=brain_file)
+            flask_api.start()
+
         # create an order listener object. This last will the trigger callback before starting
         self.order_listener = OrderListener(self.analyse_order)
         # Wait that the kalliope trigger is pronounced by the user

+ 23 - 0
core/Models/RestAPI.py

@@ -0,0 +1,23 @@
+class RestAPI(object):
+    def __init__(self, password_protected=None, login=None, password=None, active=None, port=None):
+        """
+
+        :param password_protected: If true, the rest api will ask for an authentication
+        :param login: login used if auth is activated
+        :param password: password used if auth is activated
+        :param active: specify if the rest api is loaded on start with Kalliope
+        """
+        self.password_protected = password_protected
+        self.login = login
+        self.password = password
+        self.active = active
+        self.port = port
+
+    def __str__(self):
+        return "%s: RestAPI: password_protected: %s, login: %s, " \
+               "password: %s, active: %s, port: %s" % (self.__class__.__name__,
+                                                       self.password_protected,
+                                                       self.login,
+                                                       self.password,
+                                                       self.active,
+                                                       self.port)

+ 2 - 1
core/Models/Settings.py

@@ -3,7 +3,7 @@
 class Settings(object):
     def __init__(self, default_tts_name=None, default_stt_name=None,
                  default_trigger_name=None, ttss=None, stts=None,
-                 random_wake_up_answers=None, random_wake_up_sounds=None, triggers=None):
+                 random_wake_up_answers=None, random_wake_up_sounds=None, triggers=None, rest_api=None):
         self.default_tts_name = default_tts_name
         self.default_stt_name = default_stt_name
         self.default_trigger_name = default_trigger_name
@@ -12,3 +12,4 @@ class Settings(object):
         self.random_wake_up_answers = random_wake_up_answers
         self.random_wake_up_sounds = random_wake_up_sounds
         self.triggers = triggers
+        self.rest_api = rest_api

+ 81 - 0
core/RestAPI/FlaskAPI.py

@@ -0,0 +1,81 @@
+import threading
+
+from flask import jsonify
+
+from core.ConfigurationManager.BrainLoader import BrainLoader
+from core.RestAPI.utils import requires_auth
+from core.SynapseLauncher import SynapseLauncher
+
+
+class FlaskAPI(threading.Thread):
+    def __init__(self, app, port=5000, brain_file=None):
+        super(FlaskAPI, self).__init__()
+        self.app = app
+        self.port = port
+        self.brain_file = brain_file
+        self.brain_yaml = BrainLoader.get_yaml_config(file_path=self.brain_file)
+
+        self.app.add_url_rule('/synapses/', view_func=self.get_synapses, methods=['GET'])
+        self.app.add_url_rule('/synapses/<synapse_name>', view_func=self.get_synapse, methods=['GET'])
+        self.app.add_url_rule('/synapses/<synapse_name>', view_func=self.run_synapse, methods=['POST'])
+
+    def run(self):
+        self.app.run(host='0.0.0.0', port="%s" % int(self.port), debug=True, threaded=True, use_reloader=False)
+
+    def _get_synapse_by_name(self, synapse_name):
+        """
+        Find a synapse in the brain by its name
+        :param synapse_name:
+        :return:
+        """
+        all_synapse = self.brain_yaml
+        for el in all_synapse:
+            print el
+            if el[0]["name"] in synapse_name:
+                return el[0]
+        return None
+
+    @requires_auth
+    def get_synapses(self):
+        """
+        get all synapse
+        """
+        data = jsonify(synapses=self.brain_yaml)
+        return data, 200
+
+    @requires_auth
+    def get_synapse(self, synapse_name):
+        """
+        get a synapse by its name
+        """
+        synapse_target = self._get_synapse_by_name(synapse_name)
+        if synapse_target is not None:
+            data = jsonify(synapses=synapse_target)
+            return data, 200
+
+        data = {
+            "synapse name not found": "%s" % synapse_name
+        }
+        return jsonify(error=data), 404
+
+    @requires_auth
+    def run_synapse(self, synapse_name):
+        """
+        Run a synapse by its name
+        test with curl:
+        curl -i --user admin:secret -X POST  http://localhost:5000/synapses/say-hello
+        :param synapse_name:
+        :return:
+        """
+        synapse_target = self._get_synapse_by_name(synapse_name)
+
+        if synapse_target is None:
+            data = {
+                "synapse name not found": "%s" % synapse_name
+            }
+            return jsonify(error=data), 404
+
+        # run the synapse
+        SynapseLauncher.start_synapse(synapse_name, brain_file=self.brain_file)
+        data = jsonify(synapses=synapse_target)
+        return data, 201

+ 0 - 0
core/RestAPI/__init__.py


+ 32 - 0
core/RestAPI/utils.py

@@ -0,0 +1,32 @@
+from functools import wraps
+from flask import request, Response
+
+from core.ConfigurationManager import SettingLoader
+
+
+def check_auth(username, password):
+    """This function is called to check if a username /
+    password combination is valid.
+    """
+    settings = SettingLoader.get_settings()
+    return username == settings.rest_api.login and password == settings.rest_api.password
+
+
+def authenticate():
+    """Sends a 401 response that enables basic auth"""
+    return Response(
+        'Could not verify your access level for that URL.\n'
+        'You have to login with proper credentials', 401,
+        {'WWW-Authenticate': 'Basic realm="Login Required"'})
+
+
+def requires_auth(f):
+    @wraps(f)
+    def decorated(*args, **kwargs):
+        settings = SettingLoader.get_settings()
+        if settings.rest_api.password_protected:
+            auth = request.authorization
+            if not auth or not check_auth(auth.username, auth.password):
+                return authenticate()
+        return f(*args, **kwargs)
+    return decorated

+ 11 - 0
settings.yml

@@ -95,3 +95,14 @@ random_wake_up_sounds:
   - "ding.wav"
   - "dong.wav"
   # - "/my/personal/full/path/my_file.mp3"
+
+
+# ---------------------------
+# Rest API
+# ---------------------------
+rest_api:
+  active: True
+  port: 5000
+  password_protected: True
+  login: admin
+  password: secret

+ 17 - 3
test.py

@@ -3,8 +3,13 @@ import logging
 import re
 from collections import Counter
 
+from flask import Flask
+from core.RestAPI.FlaskAPI import FlaskAPI
 from core import OrderAnalyser
+from core.ConfigurationManager import SettingLoader
 from core.ConfigurationManager import YAMLLoader
+from core.ConfigurationManager.BrainLoader import BrainLoader
+
 from neurons import Systemdate
 from neurons.tasker_autoremote.tasker_autoremote import Tasker_autoremote
 
@@ -12,9 +17,18 @@ logging.basicConfig()
 logger = logging.getLogger("kalliope")
 logger.setLevel(logging.DEBUG)
 
-order = "bonjour"
-oa = OrderAnalyser(order=order)
-oa.start()
+# order = "quelle heure est-il"
+# oa = OrderAnalyser(order=order)
+# oa.start()
+
+SettingLoader.get_settings()
+
+
+# app = Flask(__name__)
+# flask_api = FlaskAPI(app)
+# flask_api.start()
+
+