changeset 2667:8dd9db785ac8

plugin XEP-0050, adhoc D-Bus: Ad-Hoc improvment + remote media control: - "commands" namespace is now registered - added "do" and "getCommandElt" methods to XEP-0050 to run ad-hoc commands from backend - commands for a profile are now stored in client._XEP_0050_commands - Ad-Hoc D-Bus plugin can now be run without lxml or dbus (degraded) - use MPRIS to control media players - new adHocRemotesGet bridge method retrieve media players announced in all devices of the profile
author Goffi <goffi@goffi.org>
date Fri, 31 Aug 2018 15:47:00 +0200
parents bc122b68eacd
children c274201cea94
files sat/plugins/plugin_adhoc_dbus.py sat/plugins/plugin_xep_0050.py
diffstat 2 files changed, 419 insertions(+), 207 deletions(-) [+]
line wrap: on
line diff
--- a/sat/plugins/plugin_adhoc_dbus.py	Fri Aug 31 15:29:25 2018 +0200
+++ b/sat/plugins/plugin_adhoc_dbus.py	Fri Aug 31 15:47:00 2018 +0200
@@ -17,31 +17,60 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
+from sat.core.i18n import D_, _
 from sat.core.constants import Const as C
 from sat.core.log import getLogger
 
 log = getLogger(__name__)
-from sat.core import exceptions
 from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
 from wokkel import data_form
 
 try:
     from lxml import etree
 except ImportError:
-    raise exceptions.MissingModule(
-        u"Missing module lxml, please download/install it from http://lxml.de/"
-    )
+    etree = None
+    log.warning(u"Missing module lxml, please download/install it from http://lxml.de/"
+                u"auto D-Bus discovery will be disabled")
+from collections import OrderedDict
 import os.path
 import uuid
-import dbus
-from dbus.mainloop.glib import DBusGMainLoop
+try:
+    import dbus
+    from dbus.mainloop.glib import DBusGMainLoop
+except ImportError:
+    dbus = None
+    log.warning(u"Missing module dbus, please download/install it"
+                u"auto D-Bus discovery will be disabled")
 
-DBusGMainLoop(set_as_default=True)
+else:
+    DBusGMainLoop(set_as_default=True)
 
+NS_MEDIA_PLAYER = "org.salutatoi.mediaplayer"
 FD_NAME = "org.freedesktop.DBus"
 FD_PATH = "/org/freedekstop/DBus"
 INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable"
+MPRIS_PREFIX = u"org.mpris.MediaPlayer2"
+CMD_GO_BACK = u"GoBack"
+CMD_GO_FWD = u"GoFW"
+SEEK_OFFSET = 5 * 1000 * 1000
+MPRIS_COMMANDS = [u"org.mpris.MediaPlayer2.Player." + cmd for cmd in (
+    u"Previous", CMD_GO_BACK, u"PlayPause", CMD_GO_FWD, u"Next")]
+MPRIS_PATH = u"/org/mpris/MediaPlayer2"
+MPRIS_PROPERTIES = OrderedDict((
+    (u"org.mpris.MediaPlayer2", (
+        "Identity",
+        )),
+    (u"org.mpris.MediaPlayer2.Player", (
+        "Metadata",
+        "PlaybackStatus",
+        "Volume",
+        )),
+    ))
+MPRIS_METADATA_KEY = "Metadata"
+MPRIS_METADATA_MAP = OrderedDict((
+    ("xesam:title", u"Title"),
+    ))
 
 INTROSPECT_METHOD = "Introspect"
 IGNORED_IFACES_START = (
@@ -59,32 +88,56 @@
     C.PI_DEPENDENCIES: ["XEP-0050"],
     C.PI_MAIN: "AdHocDBus",
     C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Add D-Bus management to Ad-Hoc commands"""),
+    C.PI_DESCRIPTION: _(u"""Add D-Bus management to Ad-Hoc commands"""),
 }
 
 
 class AdHocDBus(object):
+
     def __init__(self, host):
         log.info(_("plugin Ad-Hoc D-Bus initialization"))
         self.host = host
+        if etree is not None:
+            host.bridge.addMethod(
+                "adHocDBusAddAuto",
+                ".plugin",
+                in_sign="sasasasasasass",
+                out_sign="(sa(sss))",
+                method=self._adHocDBusAddAuto,
+                async=True,
+            )
         host.bridge.addMethod(
-            "adHocDBusAddAuto",
+            "adHocRemotesGet",
             ".plugin",
-            in_sign="sasasasasasass",
-            out_sign="(sa(sss))",
-            method=self._adHocDBusAddAuto,
+            in_sign="s",
+            out_sign="a(sss)",
+            method=self._adHocRemotesGet,
             async=True,
         )
-        self.session_bus = dbus.SessionBus()
-        self.fd_object = self.session_bus.get_object(FD_NAME, FD_PATH, introspect=False)
-        self.XEP_0050 = host.plugins["XEP-0050"]
+        self._c = host.plugins["XEP-0050"]
+        host.registerNamespace(u"mediaplayer", NS_MEDIA_PLAYER)
+        if dbus is not None:
+            self.session_bus = dbus.SessionBus()
+            self.fd_object = self.session_bus.get_object(
+                FD_NAME, FD_PATH, introspect=False)
+
+    def profileConnected(self, client):
+        if dbus is not None:
+            self._c.addAdHocCommand(
+                client, self.localMediaCb, D_(u"Media Players"),
+                node=NS_MEDIA_PLAYER,
+                timeout=60*60*6  # 6 hours timeout, to avoid breaking remote
+                                 # in the middle of a movie
+            )
 
     def _DBusAsyncCall(self, proxy, method, *args, **kwargs):
         """ Call a DBus method asynchronously and return a deferred
+
         @param proxy: DBus object proxy, as returner by get_object
         @param method: name of the method to call
         @param args: will be transmitted to the method
-        @param kwargs: will be transmetted to the method, except for the following poped values:
+        @param kwargs: will be transmetted to the method, except for the following poped
+                       values:
                        - interface: name of the interface to use
         @return: a deferred
 
@@ -96,6 +149,11 @@
         proxy.get_dbus_method(method, dbus_interface=interface)(*args, **kwargs)
         return d
 
+    def _DBusGetProperty(self, proxy, interface, name):
+        return self._DBusAsyncCall(
+            proxy, u"Get", interface, name, interface=u"org.freedesktop.DBus.Properties")
+
+
     def _DBusListNames(self):
         return self._DBusAsyncCall(self.fd_object, "ListNames")
 
@@ -138,40 +196,17 @@
                         log.debug("method accepted: [%s]" % method_name)
                         methods.add((proxy.object_path, name, method_name))
 
-    def _adHocDBusAddAuto(
-        self,
-        prog_name,
-        allowed_jids,
-        allowed_groups,
-        allowed_magics,
-        forbidden_jids,
-        forbidden_groups,
-        flags,
-        profile_key,
-    ):
+    def _adHocDBusAddAuto(self, prog_name, allowed_jids, allowed_groups, allowed_magics,
+                          forbidden_jids, forbidden_groups, flags, profile_key):
+        client = self.host.getClient(profile_key)
         return self.adHocDBusAddAuto(
-            prog_name,
-            allowed_jids,
-            allowed_groups,
-            allowed_magics,
-            forbidden_jids,
-            forbidden_groups,
-            flags,
-            profile_key,
-        )
+            client, prog_name, allowed_jids, allowed_groups, allowed_magics,
+            forbidden_jids, forbidden_groups, flags)
 
     @defer.inlineCallbacks
-    def adHocDBusAddAuto(
-        self,
-        prog_name,
-        allowed_jids=None,
-        allowed_groups=None,
-        allowed_magics=None,
-        forbidden_jids=None,
-        forbidden_groups=None,
-        flags=None,
-        profile_key=C.PROF_KEY_NONE,
-    ):
+    def adHocDBusAddAuto(self, client, prog_name, allowed_jids=None, allowed_groups=None,
+                         allowed_magics=None, forbidden_jids=None, forbidden_groups=None,
+                         flags=None):
         bus_names = yield self._DBusListNames()
         bus_names = [bus_name for bus_name in bus_names if "." + prog_name in bus_name]
         if not bus_names:
@@ -189,6 +224,7 @@
 
         if methods:
             self._addCommand(
+                client,
                 prog_name,
                 bus_name,
                 methods,
@@ -198,35 +234,24 @@
                 forbidden_jids=forbidden_jids,
                 forbidden_groups=forbidden_groups,
                 flags=flags,
-                profile_key=profile_key,
             )
 
         defer.returnValue((bus_name, methods))
 
-    def _addCommand(
-        self,
-        adhoc_name,
-        bus_name,
-        methods,
-        allowed_jids=None,
-        allowed_groups=None,
-        allowed_magics=None,
-        forbidden_jids=None,
-        forbidden_groups=None,
-        flags=None,
-        profile_key=C.PROF_KEY_NONE,
-    ):
+    def _addCommand(self, client, adhoc_name, bus_name, methods, allowed_jids=None,
+                    allowed_groups=None, allowed_magics=None, forbidden_jids=None,
+                    forbidden_groups=None, flags=None):
         if flags is None:
             flags = set()
 
-        def DBusCallback(command_elt, session_data, action, node, profile):
+        def DBusCallback(client, command_elt, session_data, action, node):
             actions = session_data.setdefault("actions", [])
             names_map = session_data.setdefault("names_map", {})
             actions.append(action)
 
             if len(actions) == 1:
                 # it's our first request, we ask the desired new status
-                status = self.XEP_0050.STATUS.EXECUTING
+                status = self._c.STATUS.EXECUTING
                 form = data_form.Form("form", title=_("Command selection"))
                 options = []
                 for path, iface, command in methods:
@@ -250,10 +275,10 @@
                     answer_form = data_form.Form.fromElement(x_elt)
                     command = answer_form["command"]
                 except (KeyError, StopIteration):
-                    raise self.XEP_0050.AdHocError(self.XEP_0050.ERROR.BAD_PAYLOAD)
+                    raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
 
                 if command not in names_map:
-                    raise self.XEP_0050.AdHocError(self.XEP_0050.ERROR.BAD_PAYLOAD)
+                    raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
 
                 path, iface, command = names_map[command]
                 proxy = self.session_bus.get_object(bus_name, path)
@@ -262,23 +287,26 @@
 
                 # job done, we can end the session, except if we have FLAG_LOOP
                 if FLAG_LOOP in flags:
-                    # We have a loop, so we clear everything and we execute again the command as we had a first call (command_elt is not used, so None is OK)
+                    # We have a loop, so we clear everything and we execute again the
+                    # command as we had a first call (command_elt is not used, so None
+                    # is OK)
                     del actions[:]
                     names_map.clear()
                     return DBusCallback(
-                        None, session_data, self.XEP_0050.ACTION.EXECUTE, node, profile
+                        client, None, session_data, self._c.ACTION.EXECUTE, node
                     )
                 form = data_form.Form("form", title=_(u"Updated"))
                 form.addField(data_form.Field("fixed", u"Command sent"))
-                status = self.XEP_0050.STATUS.COMPLETED
+                status = self._c.STATUS.COMPLETED
                 payload = None
-                note = (self.XEP_0050.NOTE.INFO, _(u"Command sent"))
+                note = (self._c.NOTE.INFO, _(u"Command sent"))
             else:
-                raise self.XEP_0050.AdHocError(self.XEP_0050.ERROR.INTERNAL)
+                raise self._c.AdHocError(self._c.ERROR.INTERNAL)
 
             return (payload, status, None, note)
 
-        self.XEP_0050.addAdHocCommand(
+        self._c.addAdHocCommand(
+            client,
             DBusCallback,
             adhoc_name,
             allowed_jids=allowed_jids,
@@ -286,5 +314,165 @@
             allowed_magics=allowed_magics,
             forbidden_jids=forbidden_jids,
             forbidden_groups=forbidden_groups,
-            profile_key=profile_key,
         )
+
+    ## Local media ##
+
+    def _adHocRemotesGet(self, profile):
+        return self.adHocRemotesGet(self.host.getClient(profile))
+
+    @defer.inlineCallbacks
+    def adHocRemotesGet(self, client):
+        """Retrieve available remote media controlers in our devices
+        @return (list[tuple[unicode, unicode, unicode]]): list of devices with:
+            - entity full jid
+            - device name
+            - device label
+        """
+        found_data = yield self.host.findByFeatures(
+            client, [self.host.ns_map['commands']], service=False, roster=False,
+            own_jid=True, local_device=True)
+
+        remotes = []
+
+        for found in found_data:
+            for device_jid_s in found:
+                device_jid = jid.JID(device_jid_s)
+                cmd_list = yield self._c.list(client, device_jid)
+                for cmd in cmd_list:
+                    if cmd.nodeIdentifier == NS_MEDIA_PLAYER:
+                        try:
+                            result_elt = yield self._c.do(client, device_jid,
+                                                          NS_MEDIA_PLAYER, timeout=5)
+                            command_elt = self._c.getCommandElt(result_elt)
+                            form = data_form.findForm(command_elt, NS_MEDIA_PLAYER)
+                            mp_options = form.fields['media_player'].options
+                            session_id = command_elt.getAttribute('sessionid')
+                            if mp_options and session_id:
+                                # we just want to discover player, so we cancel the
+                                # session
+                                self._c.do(client, device_jid, NS_MEDIA_PLAYER,
+                                           action=self._c.ACTION.CANCEL,
+                                           session_id=session_id)
+
+                            for opt in mp_options:
+                                remotes.append((device_jid_s,
+                                                opt.value,
+                                                opt.label or opt.value))
+                        except Exception as e:
+                            log.warning(_(
+                                u"Can't retrieve remote controllers on {device_jid}: "
+                                u"{reason}".format(device_jid=device_jid, reason=e)))
+                        break
+        defer.returnValue(remotes)
+
+    def doMPRISCommand(self, proxy, command):
+        iface, command = command.rsplit(u".", 1)
+        if command == CMD_GO_BACK:
+            command = u'Seek'
+            args = [-SEEK_OFFSET]
+        elif command == CMD_GO_FWD:
+            command = u'Seek'
+            args = [SEEK_OFFSET]
+        else:
+            args = []
+        return self._DBusAsyncCall(proxy, command, *args, interface=iface)
+
+    def addMPRISMetadata(self, form, metadata):
+        """Serialise MRPIS Metadata according to MPRIS_METADATA_MAP"""
+        for mpris_key, name in MPRIS_METADATA_MAP.iteritems():
+            if mpris_key in metadata:
+                value = unicode(metadata[mpris_key])
+                form.addField(data_form.Field(fieldType=u"fixed",
+                                              var=name,
+                                              value=value))
+
+    @defer.inlineCallbacks
+    def localMediaCb(self, client, command_elt, session_data, action, node):
+        try:
+            x_elt = command_elt.elements(data_form.NS_X_DATA, "x").next()
+            command_form = data_form.Form.fromElement(x_elt)
+        except StopIteration:
+            command_form = None
+
+        if command_form is None or len(command_form.fields) == 0:
+            # root request, we looks for media players
+            bus_names = yield self._DBusListNames()
+            bus_names = [b for b in bus_names if b.startswith(MPRIS_PREFIX)]
+            if len(bus_names) == 0:
+                note = (self._c.NOTE.INFO, D_(u"No media player found."))
+                defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
+            options = []
+            status = self._c.STATUS.EXECUTING
+            form = data_form.Form("form", title=D_(u"Media Player Selection"),
+                                  formNamespace=NS_MEDIA_PLAYER)
+            for bus in bus_names:
+                player_name = bus[len(MPRIS_PREFIX)+1:]
+                if not player_name:
+                    log.warning(_(u"Ignoring MPRIS bus without suffix"))
+                    continue
+                options.append(data_form.Option(bus, player_name))
+            field = data_form.Field(
+                "list-single", "media_player", options=options, required=True
+            )
+            form.addField(field)
+            payload = form.toElement()
+            defer.returnValue((payload, status, None, None))
+        else:
+            # player request
+            try:
+                bus_name = command_form[u"media_player"]
+            except KeyError:
+                raise ValueError(_(u"missing media_player value"))
+
+            if not bus_name.startswith(MPRIS_PREFIX):
+                log.warning(_(u"Media player ad-hoc command trying to use non MPRIS bus. "
+                              u"Hack attempt? Refused bus: {bus_name}").format(
+                              bus_name=bus_name))
+                note = (self._c.NOTE.ERROR, D_(u"Invalid player name."))
+                defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
+
+            try:
+                proxy = self.session_bus.get_object(bus_name, MPRIS_PATH)
+            except dbus.exceptions.DBusException as e:
+                log.warning(_(u"Can't get D-Bus proxy: {reason}").format(reason=e))
+                note = (self._c.NOTE.ERROR, D_(u"Media player is not available anymore"))
+                defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
+            try:
+                command = command_form[u"command"]
+            except KeyError:
+                pass
+            else:
+                yield self.doMPRISCommand(proxy, command)
+
+            # we construct the remote control form
+            form = data_form.Form("form", title=D_(u"Media Player Selection"))
+            form.addField(data_form.Field(fieldType=u"hidden",
+                                          var=u"media_player",
+                                          value=bus_name))
+            for iface, properties_names in MPRIS_PROPERTIES.iteritems():
+                for name in properties_names:
+                    try:
+                        value = yield self._DBusGetProperty(proxy, iface, name)
+                    except Exception as e:
+                        log.warning(_(u"Can't retrieve attribute {name}: {reason}")
+                                    .format(name=name, reason=e))
+                        continue
+                    if name == MPRIS_METADATA_KEY:
+                        self.addMPRISMetadata(form, value)
+                    else:
+                        form.addField(data_form.Field(fieldType=u"fixed",
+                                                      var=name,
+                                                      value=unicode(value)))
+
+            commands = [data_form.Option(c, c.rsplit(u".", 1)[1]) for c in MPRIS_COMMANDS]
+            form.addField(data_form.Field(fieldType=u"list-single",
+                                          var=u"command",
+                                          options=commands,
+                                          required=True))
+
+            payload = form.toElement()
+            status = self._c.STATUS.EXECUTING
+            defer.returnValue((payload, status, None, None))
+
+
--- a/sat/plugins/plugin_xep_0050.py	Fri Aug 31 15:29:25 2018 +0200
+++ b/sat/plugins/plugin_xep_0050.py	Fri Aug 31 15:47:00 2018 +0200
@@ -26,7 +26,7 @@
 from twisted.words.protocols import jabber
 from twisted.words.xish import domish
 from twisted.internet import defer
-from wokkel import disco, iwokkel, data_form, compat
+from wokkel import disco, iwokkel, data_form
 from sat.core import exceptions
 from sat.memory.memory import Sessions
 from uuid import uuid4
@@ -70,7 +70,7 @@
     C.PI_PROTOCOLS: ["XEP-0050"],
     C.PI_MAIN: "XEP_0050",
     C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Ad-Hoc Commands"""),
+    C.PI_DESCRIPTION: _(u"""Implementation of Ad-Hoc Commands"""),
 }
 
 
@@ -86,36 +86,33 @@
 class AdHocCommand(XMPPHandler):
     implements(iwokkel.IDisco)
 
-    def __init__(
-        self,
-        parent,
-        callback,
-        label,
-        node,
-        features,
-        timeout,
-        allowed_jids,
-        allowed_groups,
-        allowed_magics,
-        forbidden_jids,
-        forbidden_groups,
-        client,
-    ):
-        self.parent = parent
+    def __init__(self, callback, label, node, features, timeout,
+                 allowed_jids, allowed_groups, allowed_magics, forbidden_jids,
+                forbidden_groups):
+        XMPPHandler.__init__(self)
         self.callback = callback
         self.label = label
         self.node = node
         self.features = [disco.DiscoFeature(feature) for feature in features]
-        self.allowed_jids, self.allowed_groups, self.allowed_magics, self.forbidden_jids, self.forbidden_groups = (
+        (
+            self.allowed_jids,
+            self.allowed_groups,
+            self.allowed_magics,
+            self.forbidden_jids,
+            self.forbidden_groups,
+        ) = (
             allowed_jids,
             allowed_groups,
             allowed_magics,
             forbidden_jids,
             forbidden_groups,
         )
-        self.client = client
         self.sessions = Sessions(timeout=timeout)
 
+    @property
+    def client(self):
+        return self.parent
+
     def getName(self, xml_lang=None):
         return self.label
 
@@ -132,12 +129,8 @@
             try:
                 allowed.update(self.client.roster.getJidsFromGroup(group))
             except exceptions.UnknownGroupError:
-                log.warning(
-                    _(
-                        u"The groups [%(group)s] is unknown for profile [%(profile)s])"
-                        % {"group": group, "profile": self.client.profile}
-                    )
-                )
+                log.warning(_(u"The groups [{group}] is unknown for profile [{profile}])")
+                            .format(group=group, profile=self.client.profile))
         if requestor.userhostJID() in allowed:
             return True
         return False
@@ -155,12 +148,15 @@
 
     def _sendAnswer(self, callback_data, session_id, request):
         """ Send result of the command
+
         @param callback_data: tuple (payload, status, actions, note) with:
-            - payload (domish.Element) usualy containing data form
+            - payload (domish.Element, None) usualy containing data form
             - status: current status, see XEP_0050.STATUS
-            - actions: list of allowed actions (see XEP_0050.ACTION). First action is the default one. Default to EXECUTE
-            - note: optional additional note: either None or a tuple with (note type, human readable string),
-                    note type being in XEP_0050.NOTE
+            - actions(list[str], None): list of allowed actions (see XEP_0050.ACTION).
+                       First action is the default one. Default to EXECUTE
+            - note(tuple[str, unicode]): optional additional note: either None or a
+                tuple with (note type, human readable string), "note type" being in
+                XEP_0050.NOTE
         @param session_id: current session id
         @param request: original request (domish.Element)
         @return: deferred
@@ -200,6 +196,7 @@
 
     def _sendError(self, error_constant, session_id, request):
         """ Send error stanza
+
         @param error_constant: one of XEP_OO50.ERROR
         @param request: original request (domish.Element)
         """
@@ -235,11 +232,11 @@
         else:
             d = defer.maybeDeferred(
                 self.callback,
+                self.client,
                 command_elt,
                 session_data,
                 action,
                 self.node,
-                self.client.profile,
             )
         d.addCallback(self._sendAnswer, session_id, command_elt.parent)
         d.addErrback(
@@ -289,7 +286,6 @@
         log.info(_("plugin XEP-0050 initialization"))
         self.host = host
         self.requesting = Sessions()
-        self.answering = {}
         host.bridge.addMethod(
             "adHocRun",
             ".plugin",
@@ -303,7 +299,7 @@
             ".plugin",
             in_sign="ss",
             out_sign="s",
-            method=self._list,
+            method=self._listUI,
             async=True,
         )
         self.__requesting_id = host.registerCallback(
@@ -315,23 +311,53 @@
             security_limit=2,
             help_string=D_("Execute ad-hoc commands"),
         )
+        host.registerNamespace(u'commands', NS_COMMANDS)
 
     def getHandler(self, client):
         return XEP_0050_handler(self)
 
     def profileConnected(self, client):
-        self.addAdHocCommand(
-            self._statusCallback, _("Status"), profile_key=client.profile
-        )
+        # map from node to AdHocCommand instance
+        client._XEP_0050_commands = {}
+        self.addAdHocCommand(client, self._statusCallback, _("Status"))
+
+    def do(self, client, entity, node, action=ACTION.EXECUTE, session_id=None,
+           form_values=None, timeout=30):
+        """Do an Ad-Hoc Command
 
-    def profileDisconnected(self, client):
+        @param entity(jid.JID): entity which will execture the command
+        @param node(unicode): node of the command
+        @param action(unicode): one of XEP_0050.ACTION
+        @param session_id(unicode, None): id of the ad-hoc session
+            None if no session is involved
+        @param form_values(dict, None): values to use to create command form
+            values will be passed to data_form.Form.makeFields
+        @return
+        """
+        iq_elt = client.IQ(timeout=timeout)
+        iq_elt["to"] = entity.full()
+        command_elt = iq_elt.addElement("command", NS_COMMANDS)
+        command_elt["node"] = node
+        command_elt["action"] = action
+        if session_id is not None:
+            command_elt["sessionid"] = session_id
+
+        if form_values:
+            # We add the XMLUI result to the command payload
+            form = data_form.Form("submit")
+            form.makeFields(form_values)
+            command_elt.addChild(form.toElement())
+        d = iq_elt.send()
+        return d
+
+    def getCommandElt(self, iq_elt):
         try:
-            del self.answering[client.profile]
-        except KeyError:
-            pass
+            return iq_elt.elements(NS_COMMANDS, "command").next()
+        except StopIteration:
+            raise exceptions.NotFound(_(u"Missing command element"))
 
     def _items2XMLUI(self, items, no_instructions):
-        """ Convert discovery items to XMLUI dialog """
+        """Convert discovery items to XMLUI dialog """
         # TODO: manage items on different jids
         form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)
 
@@ -371,14 +397,13 @@
         return [u"%s%s" % (lvl_map[lvl], msg) for lvl, msg in notes]
 
     def _commandsAnswer2XMLUI(self, iq_elt, session_id, session_data):
-        """
-        Convert command answer to an ui for frontend
+        """Convert command answer to an ui for frontend
+
         @param iq_elt: command result
         @param session_id: id of the session used with the frontend
         @param profile_key: %(doc_profile_key)s
-
         """
-        command_elt = iq_elt.elements(NS_COMMANDS, "command").next()
+        command_elt = self.getCommandElt(iq_elt)
         status = command_elt.getAttribute("status", XEP_0050.STATUS.EXECUTING)
         if status in [XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED]:
             # the command session is finished, we purge our session
@@ -451,15 +476,17 @@
         return d
 
     def requestingEntity(self, data, profile):
-        """
-        request and entity and create XMLUI accordingly
-        @param data: data returned by previous XMLUI (first one must come from self._commandsMenu)
+        """Request and entity and create XMLUI accordingly.
+
+        @param data: data returned by previous XMLUI (first one must come from
+                     self._commandsMenu)
         @param profile: %(doc_profile)s
-        @return: callback dict result (with "xmlui" corresponding to the answering dialog, or empty if it's finished without error)
-
+        @return: callback dict result (with "xmlui" corresponding to the answering
+                 dialog, or empty if it's finished without error)
         """
         if C.bool(data.get("cancelled", C.BOOL_FALSE)):
             return defer.succeed({})
+        data_form_values = xml_tools.XMLUIResult2DataFormResult(data)
         client = self.host.getClient(profile)
         # TODO: cancel, prev and next are not managed
         # TODO: managed answerer errors
@@ -469,7 +496,7 @@
             session_id, session_data = self.requesting.newSession(profile=client.profile)
             entity = jid.JID(data[xml_tools.SAT_FORM_PREFIX + "jid"])
             session_data["jid"] = entity
-            d = self.list(client, entity)
+            d = self.listUI(client, entity)
 
             def sendItems(xmlui):
                 xmlui.session_id = session_id  # we need to keep track of the session
@@ -493,34 +520,24 @@
                 # node has already been received
             except KeyError:
                 # it's the first time we know the node, we save it in session data
-                session_data["node"] = data[xml_tools.SAT_FORM_PREFIX + "node"]
+                session_data["node"] = data_form_values.pop("node")
+
+            # remote_id is the XEP_0050 sessionid used by answering command
+            # while session_id is our own session id used with the frontend
+            remote_id = session_data.get("remote_id")
 
             # we request execute node's command
-            iq_elt = compat.IQ(client.xmlstream, "set")
-            iq_elt["to"] = entity.full()
-            command_elt = iq_elt.addElement("command", NS_COMMANDS)
-            command_elt["node"] = session_data["node"]
-            command_elt["action"] = XEP_0050.ACTION.EXECUTE
-            try:
-                # remote_id is the XEP_0050 sessionid used by answering command
-                # while session_id is our own session id used with the frontend
-                command_elt["sessionid"] = session_data["remote_id"]
-            except KeyError:
-                pass
-
-            command_elt.addChild(
-                xml_tools.XMLUIResultToElt(data)
-            )  # We add the XMLUI result to the command payload
-            d = iq_elt.send()
+            d = self.do(client, entity, session_data["node"], action=XEP_0050.ACTION.EXECUTE,
+                        session_id=remote_id, form_values=data_form_values)
             d.addCallback(self._commandsAnswer2XMLUI, session_id, session_data)
             d.addCallback(lambda xmlui: {"xmlui": xmlui} if xmlui is not None else {})
 
         return d
 
     def _commandsMenu(self, menu_data, profile):
-        """ First XMLUI activated by menu: ask for target jid
+        """First XMLUI activated by menu: ask for target jid
+
         @param profile: %(doc_profile)s
-
         """
         form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)
         form_ui.addText(_("Please enter target jid"), "instructions")
@@ -529,8 +546,8 @@
         form_ui.addString("jid", value=self.host.getClient(profile).jid.host)
         return {"xmlui": form_ui.toXml()}
 
-    def _statusCallback(self, command_elt, session_data, action, node, profile):
-        """ Ad-hoc command used to change the "show" part of status """
+    def _statusCallback(self, client, command_elt, session_data, action, node):
+        """Ad-hoc command used to change the "show" part of status"""
         actions = session_data.setdefault("actions", [])
         actions.append(action)
 
@@ -560,9 +577,9 @@
             if show not in SHOWS:
                 raise AdHocError(XEP_0050.ERROR.BAD_PAYLOAD)
             if show == "disconnect":
-                self.host.disconnect(profile)
+                self.host.disconnect(client.profile)
             else:
-                self.host.setPresence(show=show, profile_key=profile)
+                self.host.setPresence(show=show, profile_key=client.profile)
 
             # job done, we can end the session
             form = data_form.Form("form", title=_(u"Updated"))
@@ -597,7 +614,7 @@
         session_id, session_data = self.requesting.newSession(profile=client.profile)
         session_data["jid"] = service_jid
         if node is None:
-            xmlui = yield self.list(client, service_jid)
+            xmlui = yield self.listUI(client, service_jid)
         else:
             session_data["node"] = node
             cb_data = yield self.requestingEntity(
@@ -608,45 +625,51 @@
         xmlui.session_id = session_id
         defer.returnValue(xmlui)
 
-    def _list(self, to_jid_s, profile_key):
+    def list(self, client, to_jid):
+        """Request available commands
+
+        @param to_jid(jid.JID, None): the entity answering the commands
+            None to use profile's server
+        @return D(disco.DiscoItems): found commands
+        """
+        d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS)
+        return d
+
+    def _listUI(self, to_jid_s, profile_key):
         client = self.host.getClient(profile_key)
         to_jid = jid.JID(to_jid_s) if to_jid_s else None
-        d = self.list(client, to_jid, no_instructions=True)
+        d = self.listUI(client, to_jid, no_instructions=True)
         d.addCallback(lambda xmlui: xmlui.toXml())
         return d
 
-    def list(self, client, to_jid, no_instructions=False):
-        """Request available commands
+    def listUI(self, client, to_jid, no_instructions=False):
+        """Request available commands and generate XMLUI
 
         @param to_jid(jid.JID, None): the entity answering the commands
             None to use profile's server
         @param no_instructions(bool): if True, don't add instructions widget
+        @return D(xml_tools.XMLUI): UI with the commands
         """
-        d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS)
+        d = self.list(client, to_jid)
         d.addCallback(self._items2XMLUI, no_instructions)
         return d
 
-    def addAdHocCommand(
-        self,
-        callback,
-        label,
-        node=None,
-        features=None,
-        timeout=600,
-        allowed_jids=None,
-        allowed_groups=None,
-        allowed_magics=None,
-        forbidden_jids=None,
-        forbidden_groups=None,
-        profile_key=C.PROF_KEY_NONE,
-    ):
+    def addAdHocCommand(self, client, callback, label, node=None, features=None,
+                        timeout=600, allowed_jids=None, allowed_groups=None,
+                        allowed_magics=None, forbidden_jids=None, forbidden_groups=None,
+                        ):
         """Add an ad-hoc command for the current profile
 
-        @param callback: method associated with this ad-hoc command which return the payload data (see AdHocCommand._sendAnswer), can return a deferred
+        @param callback: method associated with this ad-hoc command which return the
+                         payload data (see AdHocCommand._sendAnswer), can return a
+                         deferred
         @param label: label associated with this command on the main menu
-        @param node: disco item node associated with this command. None to use autogenerated node
-        @param features: features associated with the payload (list of strings), usualy data form
-        @param timeout: delay between two requests before canceling the session (in seconds)
+        @param node: disco item node associated with this command. None to use
+                     autogenerated node
+        @param features: features associated with the payload (list of strings), usualy
+                         data form
+        @param timeout: delay between two requests before canceling the session (in
+                        seconds)
         @param allowed_jids: list of allowed entities
         @param allowed_groups: list of allowed roster groups
         @param allowed_magics: list of allowed magic keys, can be:
@@ -654,7 +677,6 @@
                                @PROFILE_BAREJID@: allow only the jid of the profile
         @param forbidden_jids: black list of entities which can't access this command
         @param forbidden_groups: black list of groups which can't access this command
-        @param profile_key: profile key associated with this command, @ALL@ means can be accessed with every profiles
         @return: node of the added command, useful to remove the command later
         """
         # FIXME: "@ALL@" for profile_key seems useless and dangerous
@@ -676,44 +698,44 @@
         if forbidden_groups is None:
             forbidden_groups = []
 
-        for client in self.host.getClients(profile_key):
-            # TODO: manage newly created/removed profiles
-            _allowed_jids = (
-                (allowed_jids + [client.jid.userhostJID()])
-                if "@PROFILE_BAREJID@" in allowed_magics
-                else allowed_jids
-            )
-            ad_hoc_command = AdHocCommand(
-                self,
-                callback,
-                label,
-                node,
-                features,
-                timeout,
-                _allowed_jids,
-                allowed_groups,
-                allowed_magics,
-                forbidden_jids,
-                forbidden_groups,
-                client,
-            )
-            ad_hoc_command.setHandlerParent(client)
-            profile_commands = self.answering.setdefault(client.profile, {})
-            profile_commands[node] = ad_hoc_command
+        # TODO: manage newly created/removed profiles
+        _allowed_jids = (
+            (allowed_jids + [client.jid.userhostJID()])
+            if "@PROFILE_BAREJID@" in allowed_magics
+            else allowed_jids
+        )
+        ad_hoc_command = AdHocCommand(
+            callback,
+            label,
+            node,
+            features,
+            timeout,
+            _allowed_jids,
+            allowed_groups,
+            allowed_magics,
+            forbidden_jids,
+            forbidden_groups,
+        )
+        ad_hoc_command.setHandlerParent(client)
+        commands = client._XEP_0050_commands
+        commands[node] = ad_hoc_command
 
-    def onCmdRequest(self, request, profile):
+    def onCmdRequest(self, request, client):
         request.handled = True
         requestor = jid.JID(request["from"])
         command_elt = request.elements(NS_COMMANDS, "command").next()
         action = command_elt.getAttribute("action", self.ACTION.EXECUTE)
         node = command_elt.getAttribute("node")
         if not node:
-            raise exceptions.DataError
+            client.sendError(request, u"bad-request")
+            return
         sessionid = command_elt.getAttribute("sessionid")
+        commands = client._XEP_0050_commands
         try:
-            command = self.answering[profile][node]
+            command = commands[node]
         except KeyError:
-            raise exceptions.DataError
+            client.sendError(request, u"item-not-found")
+            return
         command.onRequest(command_elt, requestor, action, sessionid)
 
 
@@ -723,25 +745,27 @@
     def __init__(self, plugin_parent):
         self.plugin_parent = plugin_parent
 
+    @property
+    def client(self):
+        return self.parent
+
     def connectionInitialized(self):
         self.xmlstream.addObserver(
-            CMD_REQUEST, self.plugin_parent.onCmdRequest, profile=self.parent.profile
+            CMD_REQUEST, self.plugin_parent.onCmdRequest, client=self.parent
         )
 
     def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
         identities = []
-        if nodeIdentifier == NS_COMMANDS and self.plugin_parent.answering.get(
-            self.parent.profile
-        ):  # we only add the identity if we have registred commands
+        if nodeIdentifier == NS_COMMANDS and self.client._XEP_0050_commands:
+            # we only add the identity if we have registred commands
             identities.append(ID_CMD_LIST)
         return [disco.DiscoFeature(NS_COMMANDS)] + identities
 
     def getDiscoItems(self, requestor, target, nodeIdentifier=""):
         ret = []
         if nodeIdentifier == NS_COMMANDS:
-            for command in self.plugin_parent.answering.get(
-                self.parent.profile, {}
-            ).values():
+            commands = self.client._XEP_0050_commands
+            for command in commands.values():
                 if command.isAuthorised(requestor):
                     ret.append(
                         disco.DiscoItem(self.parent.jid, command.node, command.getName())