comparison sat/plugins/plugin_adhoc_dbus.py @ 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 56f94936df1e
children 003b8b4b56a7
comparison
equal deleted inserted replaced
2666:bc122b68eacd 2667:8dd9db785ac8
15 # GNU Affero General Public License for more details. 15 # GNU Affero General Public License for more details.
16 16
17 # You should have received a copy of the GNU Affero General Public License 17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 from sat.core.i18n import _ 20 from sat.core.i18n import D_, _
21 from sat.core.constants import Const as C 21 from sat.core.constants import Const as C
22 from sat.core.log import getLogger 22 from sat.core.log import getLogger
23 23
24 log = getLogger(__name__) 24 log = getLogger(__name__)
25 from sat.core import exceptions
26 from twisted.internet import defer 25 from twisted.internet import defer
26 from twisted.words.protocols.jabber import jid
27 from wokkel import data_form 27 from wokkel import data_form
28 28
29 try: 29 try:
30 from lxml import etree 30 from lxml import etree
31 except ImportError: 31 except ImportError:
32 raise exceptions.MissingModule( 32 etree = None
33 u"Missing module lxml, please download/install it from http://lxml.de/" 33 log.warning(u"Missing module lxml, please download/install it from http://lxml.de/"
34 ) 34 u"auto D-Bus discovery will be disabled")
35 from collections import OrderedDict
35 import os.path 36 import os.path
36 import uuid 37 import uuid
37 import dbus 38 try:
38 from dbus.mainloop.glib import DBusGMainLoop 39 import dbus
39 40 from dbus.mainloop.glib import DBusGMainLoop
40 DBusGMainLoop(set_as_default=True) 41 except ImportError:
41 42 dbus = None
43 log.warning(u"Missing module dbus, please download/install it"
44 u"auto D-Bus discovery will be disabled")
45
46 else:
47 DBusGMainLoop(set_as_default=True)
48
49 NS_MEDIA_PLAYER = "org.salutatoi.mediaplayer"
42 FD_NAME = "org.freedesktop.DBus" 50 FD_NAME = "org.freedesktop.DBus"
43 FD_PATH = "/org/freedekstop/DBus" 51 FD_PATH = "/org/freedekstop/DBus"
44 INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable" 52 INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable"
53 MPRIS_PREFIX = u"org.mpris.MediaPlayer2"
54 CMD_GO_BACK = u"GoBack"
55 CMD_GO_FWD = u"GoFW"
56 SEEK_OFFSET = 5 * 1000 * 1000
57 MPRIS_COMMANDS = [u"org.mpris.MediaPlayer2.Player." + cmd for cmd in (
58 u"Previous", CMD_GO_BACK, u"PlayPause", CMD_GO_FWD, u"Next")]
59 MPRIS_PATH = u"/org/mpris/MediaPlayer2"
60 MPRIS_PROPERTIES = OrderedDict((
61 (u"org.mpris.MediaPlayer2", (
62 "Identity",
63 )),
64 (u"org.mpris.MediaPlayer2.Player", (
65 "Metadata",
66 "PlaybackStatus",
67 "Volume",
68 )),
69 ))
70 MPRIS_METADATA_KEY = "Metadata"
71 MPRIS_METADATA_MAP = OrderedDict((
72 ("xesam:title", u"Title"),
73 ))
45 74
46 INTROSPECT_METHOD = "Introspect" 75 INTROSPECT_METHOD = "Introspect"
47 IGNORED_IFACES_START = ( 76 IGNORED_IFACES_START = (
48 "org.freedesktop", 77 "org.freedesktop",
49 "org.qtproject", 78 "org.qtproject",
57 C.PI_TYPE: "Misc", 86 C.PI_TYPE: "Misc",
58 C.PI_PROTOCOLS: [], 87 C.PI_PROTOCOLS: [],
59 C.PI_DEPENDENCIES: ["XEP-0050"], 88 C.PI_DEPENDENCIES: ["XEP-0050"],
60 C.PI_MAIN: "AdHocDBus", 89 C.PI_MAIN: "AdHocDBus",
61 C.PI_HANDLER: "no", 90 C.PI_HANDLER: "no",
62 C.PI_DESCRIPTION: _("""Add D-Bus management to Ad-Hoc commands"""), 91 C.PI_DESCRIPTION: _(u"""Add D-Bus management to Ad-Hoc commands"""),
63 } 92 }
64 93
65 94
66 class AdHocDBus(object): 95 class AdHocDBus(object):
96
67 def __init__(self, host): 97 def __init__(self, host):
68 log.info(_("plugin Ad-Hoc D-Bus initialization")) 98 log.info(_("plugin Ad-Hoc D-Bus initialization"))
69 self.host = host 99 self.host = host
100 if etree is not None:
101 host.bridge.addMethod(
102 "adHocDBusAddAuto",
103 ".plugin",
104 in_sign="sasasasasasass",
105 out_sign="(sa(sss))",
106 method=self._adHocDBusAddAuto,
107 async=True,
108 )
70 host.bridge.addMethod( 109 host.bridge.addMethod(
71 "adHocDBusAddAuto", 110 "adHocRemotesGet",
72 ".plugin", 111 ".plugin",
73 in_sign="sasasasasasass", 112 in_sign="s",
74 out_sign="(sa(sss))", 113 out_sign="a(sss)",
75 method=self._adHocDBusAddAuto, 114 method=self._adHocRemotesGet,
76 async=True, 115 async=True,
77 ) 116 )
78 self.session_bus = dbus.SessionBus() 117 self._c = host.plugins["XEP-0050"]
79 self.fd_object = self.session_bus.get_object(FD_NAME, FD_PATH, introspect=False) 118 host.registerNamespace(u"mediaplayer", NS_MEDIA_PLAYER)
80 self.XEP_0050 = host.plugins["XEP-0050"] 119 if dbus is not None:
120 self.session_bus = dbus.SessionBus()
121 self.fd_object = self.session_bus.get_object(
122 FD_NAME, FD_PATH, introspect=False)
123
124 def profileConnected(self, client):
125 if dbus is not None:
126 self._c.addAdHocCommand(
127 client, self.localMediaCb, D_(u"Media Players"),
128 node=NS_MEDIA_PLAYER,
129 timeout=60*60*6 # 6 hours timeout, to avoid breaking remote
130 # in the middle of a movie
131 )
81 132
82 def _DBusAsyncCall(self, proxy, method, *args, **kwargs): 133 def _DBusAsyncCall(self, proxy, method, *args, **kwargs):
83 """ Call a DBus method asynchronously and return a deferred 134 """ Call a DBus method asynchronously and return a deferred
135
84 @param proxy: DBus object proxy, as returner by get_object 136 @param proxy: DBus object proxy, as returner by get_object
85 @param method: name of the method to call 137 @param method: name of the method to call
86 @param args: will be transmitted to the method 138 @param args: will be transmitted to the method
87 @param kwargs: will be transmetted to the method, except for the following poped values: 139 @param kwargs: will be transmetted to the method, except for the following poped
140 values:
88 - interface: name of the interface to use 141 - interface: name of the interface to use
89 @return: a deferred 142 @return: a deferred
90 143
91 """ 144 """
92 d = defer.Deferred() 145 d = defer.Deferred()
93 interface = kwargs.pop("interface", None) 146 interface = kwargs.pop("interface", None)
94 kwargs["reply_handler"] = lambda ret=None: d.callback(ret) 147 kwargs["reply_handler"] = lambda ret=None: d.callback(ret)
95 kwargs["error_handler"] = d.errback 148 kwargs["error_handler"] = d.errback
96 proxy.get_dbus_method(method, dbus_interface=interface)(*args, **kwargs) 149 proxy.get_dbus_method(method, dbus_interface=interface)(*args, **kwargs)
97 return d 150 return d
151
152 def _DBusGetProperty(self, proxy, interface, name):
153 return self._DBusAsyncCall(
154 proxy, u"Get", interface, name, interface=u"org.freedesktop.DBus.Properties")
155
98 156
99 def _DBusListNames(self): 157 def _DBusListNames(self):
100 return self._DBusAsyncCall(self.fd_object, "ListNames") 158 return self._DBusAsyncCall(self.fd_object, "ListNames")
101 159
102 def _DBusIntrospect(self, proxy): 160 def _DBusIntrospect(self, proxy):
136 if self._acceptMethod(method): 194 if self._acceptMethod(method):
137 method_name = method.get("name") 195 method_name = method.get("name")
138 log.debug("method accepted: [%s]" % method_name) 196 log.debug("method accepted: [%s]" % method_name)
139 methods.add((proxy.object_path, name, method_name)) 197 methods.add((proxy.object_path, name, method_name))
140 198
141 def _adHocDBusAddAuto( 199 def _adHocDBusAddAuto(self, prog_name, allowed_jids, allowed_groups, allowed_magics,
142 self, 200 forbidden_jids, forbidden_groups, flags, profile_key):
143 prog_name, 201 client = self.host.getClient(profile_key)
144 allowed_jids,
145 allowed_groups,
146 allowed_magics,
147 forbidden_jids,
148 forbidden_groups,
149 flags,
150 profile_key,
151 ):
152 return self.adHocDBusAddAuto( 202 return self.adHocDBusAddAuto(
153 prog_name, 203 client, prog_name, allowed_jids, allowed_groups, allowed_magics,
154 allowed_jids, 204 forbidden_jids, forbidden_groups, flags)
155 allowed_groups,
156 allowed_magics,
157 forbidden_jids,
158 forbidden_groups,
159 flags,
160 profile_key,
161 )
162 205
163 @defer.inlineCallbacks 206 @defer.inlineCallbacks
164 def adHocDBusAddAuto( 207 def adHocDBusAddAuto(self, client, prog_name, allowed_jids=None, allowed_groups=None,
165 self, 208 allowed_magics=None, forbidden_jids=None, forbidden_groups=None,
166 prog_name, 209 flags=None):
167 allowed_jids=None,
168 allowed_groups=None,
169 allowed_magics=None,
170 forbidden_jids=None,
171 forbidden_groups=None,
172 flags=None,
173 profile_key=C.PROF_KEY_NONE,
174 ):
175 bus_names = yield self._DBusListNames() 210 bus_names = yield self._DBusListNames()
176 bus_names = [bus_name for bus_name in bus_names if "." + prog_name in bus_name] 211 bus_names = [bus_name for bus_name in bus_names if "." + prog_name in bus_name]
177 if not bus_names: 212 if not bus_names:
178 log.info("Can't find any bus for [%s]" % prog_name) 213 log.info("Can't find any bus for [%s]" % prog_name)
179 defer.returnValue(("", [])) 214 defer.returnValue(("", []))
187 222
188 yield self._introspect(methods, bus_name, proxy) 223 yield self._introspect(methods, bus_name, proxy)
189 224
190 if methods: 225 if methods:
191 self._addCommand( 226 self._addCommand(
227 client,
192 prog_name, 228 prog_name,
193 bus_name, 229 bus_name,
194 methods, 230 methods,
195 allowed_jids=allowed_jids, 231 allowed_jids=allowed_jids,
196 allowed_groups=allowed_groups, 232 allowed_groups=allowed_groups,
197 allowed_magics=allowed_magics, 233 allowed_magics=allowed_magics,
198 forbidden_jids=forbidden_jids, 234 forbidden_jids=forbidden_jids,
199 forbidden_groups=forbidden_groups, 235 forbidden_groups=forbidden_groups,
200 flags=flags, 236 flags=flags,
201 profile_key=profile_key,
202 ) 237 )
203 238
204 defer.returnValue((bus_name, methods)) 239 defer.returnValue((bus_name, methods))
205 240
206 def _addCommand( 241 def _addCommand(self, client, adhoc_name, bus_name, methods, allowed_jids=None,
207 self, 242 allowed_groups=None, allowed_magics=None, forbidden_jids=None,
208 adhoc_name, 243 forbidden_groups=None, flags=None):
209 bus_name,
210 methods,
211 allowed_jids=None,
212 allowed_groups=None,
213 allowed_magics=None,
214 forbidden_jids=None,
215 forbidden_groups=None,
216 flags=None,
217 profile_key=C.PROF_KEY_NONE,
218 ):
219 if flags is None: 244 if flags is None:
220 flags = set() 245 flags = set()
221 246
222 def DBusCallback(command_elt, session_data, action, node, profile): 247 def DBusCallback(client, command_elt, session_data, action, node):
223 actions = session_data.setdefault("actions", []) 248 actions = session_data.setdefault("actions", [])
224 names_map = session_data.setdefault("names_map", {}) 249 names_map = session_data.setdefault("names_map", {})
225 actions.append(action) 250 actions.append(action)
226 251
227 if len(actions) == 1: 252 if len(actions) == 1:
228 # it's our first request, we ask the desired new status 253 # it's our first request, we ask the desired new status
229 status = self.XEP_0050.STATUS.EXECUTING 254 status = self._c.STATUS.EXECUTING
230 form = data_form.Form("form", title=_("Command selection")) 255 form = data_form.Form("form", title=_("Command selection"))
231 options = [] 256 options = []
232 for path, iface, command in methods: 257 for path, iface, command in methods:
233 label = command.rsplit(".", 1)[-1] 258 label = command.rsplit(".", 1)[-1]
234 name = str(uuid.uuid4()) 259 name = str(uuid.uuid4())
248 try: 273 try:
249 x_elt = command_elt.elements(data_form.NS_X_DATA, "x").next() 274 x_elt = command_elt.elements(data_form.NS_X_DATA, "x").next()
250 answer_form = data_form.Form.fromElement(x_elt) 275 answer_form = data_form.Form.fromElement(x_elt)
251 command = answer_form["command"] 276 command = answer_form["command"]
252 except (KeyError, StopIteration): 277 except (KeyError, StopIteration):
253 raise self.XEP_0050.AdHocError(self.XEP_0050.ERROR.BAD_PAYLOAD) 278 raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
254 279
255 if command not in names_map: 280 if command not in names_map:
256 raise self.XEP_0050.AdHocError(self.XEP_0050.ERROR.BAD_PAYLOAD) 281 raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
257 282
258 path, iface, command = names_map[command] 283 path, iface, command = names_map[command]
259 proxy = self.session_bus.get_object(bus_name, path) 284 proxy = self.session_bus.get_object(bus_name, path)
260 285
261 self._DBusAsyncCall(proxy, command, interface=iface) 286 self._DBusAsyncCall(proxy, command, interface=iface)
262 287
263 # job done, we can end the session, except if we have FLAG_LOOP 288 # job done, we can end the session, except if we have FLAG_LOOP
264 if FLAG_LOOP in flags: 289 if FLAG_LOOP in flags:
265 # 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) 290 # We have a loop, so we clear everything and we execute again the
291 # command as we had a first call (command_elt is not used, so None
292 # is OK)
266 del actions[:] 293 del actions[:]
267 names_map.clear() 294 names_map.clear()
268 return DBusCallback( 295 return DBusCallback(
269 None, session_data, self.XEP_0050.ACTION.EXECUTE, node, profile 296 client, None, session_data, self._c.ACTION.EXECUTE, node
270 ) 297 )
271 form = data_form.Form("form", title=_(u"Updated")) 298 form = data_form.Form("form", title=_(u"Updated"))
272 form.addField(data_form.Field("fixed", u"Command sent")) 299 form.addField(data_form.Field("fixed", u"Command sent"))
273 status = self.XEP_0050.STATUS.COMPLETED 300 status = self._c.STATUS.COMPLETED
274 payload = None 301 payload = None
275 note = (self.XEP_0050.NOTE.INFO, _(u"Command sent")) 302 note = (self._c.NOTE.INFO, _(u"Command sent"))
276 else: 303 else:
277 raise self.XEP_0050.AdHocError(self.XEP_0050.ERROR.INTERNAL) 304 raise self._c.AdHocError(self._c.ERROR.INTERNAL)
278 305
279 return (payload, status, None, note) 306 return (payload, status, None, note)
280 307
281 self.XEP_0050.addAdHocCommand( 308 self._c.addAdHocCommand(
309 client,
282 DBusCallback, 310 DBusCallback,
283 adhoc_name, 311 adhoc_name,
284 allowed_jids=allowed_jids, 312 allowed_jids=allowed_jids,
285 allowed_groups=allowed_groups, 313 allowed_groups=allowed_groups,
286 allowed_magics=allowed_magics, 314 allowed_magics=allowed_magics,
287 forbidden_jids=forbidden_jids, 315 forbidden_jids=forbidden_jids,
288 forbidden_groups=forbidden_groups, 316 forbidden_groups=forbidden_groups,
289 profile_key=profile_key,
290 ) 317 )
318
319 ## Local media ##
320
321 def _adHocRemotesGet(self, profile):
322 return self.adHocRemotesGet(self.host.getClient(profile))
323
324 @defer.inlineCallbacks
325 def adHocRemotesGet(self, client):
326 """Retrieve available remote media controlers in our devices
327 @return (list[tuple[unicode, unicode, unicode]]): list of devices with:
328 - entity full jid
329 - device name
330 - device label
331 """
332 found_data = yield self.host.findByFeatures(
333 client, [self.host.ns_map['commands']], service=False, roster=False,
334 own_jid=True, local_device=True)
335
336 remotes = []
337
338 for found in found_data:
339 for device_jid_s in found:
340 device_jid = jid.JID(device_jid_s)
341 cmd_list = yield self._c.list(client, device_jid)
342 for cmd in cmd_list:
343 if cmd.nodeIdentifier == NS_MEDIA_PLAYER:
344 try:
345 result_elt = yield self._c.do(client, device_jid,
346 NS_MEDIA_PLAYER, timeout=5)
347 command_elt = self._c.getCommandElt(result_elt)
348 form = data_form.findForm(command_elt, NS_MEDIA_PLAYER)
349 mp_options = form.fields['media_player'].options
350 session_id = command_elt.getAttribute('sessionid')
351 if mp_options and session_id:
352 # we just want to discover player, so we cancel the
353 # session
354 self._c.do(client, device_jid, NS_MEDIA_PLAYER,
355 action=self._c.ACTION.CANCEL,
356 session_id=session_id)
357
358 for opt in mp_options:
359 remotes.append((device_jid_s,
360 opt.value,
361 opt.label or opt.value))
362 except Exception as e:
363 log.warning(_(
364 u"Can't retrieve remote controllers on {device_jid}: "
365 u"{reason}".format(device_jid=device_jid, reason=e)))
366 break
367 defer.returnValue(remotes)
368
369 def doMPRISCommand(self, proxy, command):
370 iface, command = command.rsplit(u".", 1)
371 if command == CMD_GO_BACK:
372 command = u'Seek'
373 args = [-SEEK_OFFSET]
374 elif command == CMD_GO_FWD:
375 command = u'Seek'
376 args = [SEEK_OFFSET]
377 else:
378 args = []
379 return self._DBusAsyncCall(proxy, command, *args, interface=iface)
380
381 def addMPRISMetadata(self, form, metadata):
382 """Serialise MRPIS Metadata according to MPRIS_METADATA_MAP"""
383 for mpris_key, name in MPRIS_METADATA_MAP.iteritems():
384 if mpris_key in metadata:
385 value = unicode(metadata[mpris_key])
386 form.addField(data_form.Field(fieldType=u"fixed",
387 var=name,
388 value=value))
389
390 @defer.inlineCallbacks
391 def localMediaCb(self, client, command_elt, session_data, action, node):
392 try:
393 x_elt = command_elt.elements(data_form.NS_X_DATA, "x").next()
394 command_form = data_form.Form.fromElement(x_elt)
395 except StopIteration:
396 command_form = None
397
398 if command_form is None or len(command_form.fields) == 0:
399 # root request, we looks for media players
400 bus_names = yield self._DBusListNames()
401 bus_names = [b for b in bus_names if b.startswith(MPRIS_PREFIX)]
402 if len(bus_names) == 0:
403 note = (self._c.NOTE.INFO, D_(u"No media player found."))
404 defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
405 options = []
406 status = self._c.STATUS.EXECUTING
407 form = data_form.Form("form", title=D_(u"Media Player Selection"),
408 formNamespace=NS_MEDIA_PLAYER)
409 for bus in bus_names:
410 player_name = bus[len(MPRIS_PREFIX)+1:]
411 if not player_name:
412 log.warning(_(u"Ignoring MPRIS bus without suffix"))
413 continue
414 options.append(data_form.Option(bus, player_name))
415 field = data_form.Field(
416 "list-single", "media_player", options=options, required=True
417 )
418 form.addField(field)
419 payload = form.toElement()
420 defer.returnValue((payload, status, None, None))
421 else:
422 # player request
423 try:
424 bus_name = command_form[u"media_player"]
425 except KeyError:
426 raise ValueError(_(u"missing media_player value"))
427
428 if not bus_name.startswith(MPRIS_PREFIX):
429 log.warning(_(u"Media player ad-hoc command trying to use non MPRIS bus. "
430 u"Hack attempt? Refused bus: {bus_name}").format(
431 bus_name=bus_name))
432 note = (self._c.NOTE.ERROR, D_(u"Invalid player name."))
433 defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
434
435 try:
436 proxy = self.session_bus.get_object(bus_name, MPRIS_PATH)
437 except dbus.exceptions.DBusException as e:
438 log.warning(_(u"Can't get D-Bus proxy: {reason}").format(reason=e))
439 note = (self._c.NOTE.ERROR, D_(u"Media player is not available anymore"))
440 defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
441 try:
442 command = command_form[u"command"]
443 except KeyError:
444 pass
445 else:
446 yield self.doMPRISCommand(proxy, command)
447
448 # we construct the remote control form
449 form = data_form.Form("form", title=D_(u"Media Player Selection"))
450 form.addField(data_form.Field(fieldType=u"hidden",
451 var=u"media_player",
452 value=bus_name))
453 for iface, properties_names in MPRIS_PROPERTIES.iteritems():
454 for name in properties_names:
455 try:
456 value = yield self._DBusGetProperty(proxy, iface, name)
457 except Exception as e:
458 log.warning(_(u"Can't retrieve attribute {name}: {reason}")
459 .format(name=name, reason=e))
460 continue
461 if name == MPRIS_METADATA_KEY:
462 self.addMPRISMetadata(form, value)
463 else:
464 form.addField(data_form.Field(fieldType=u"fixed",
465 var=name,
466 value=unicode(value)))
467
468 commands = [data_form.Option(c, c.rsplit(u".", 1)[1]) for c in MPRIS_COMMANDS]
469 form.addField(data_form.Field(fieldType=u"list-single",
470 var=u"command",
471 options=commands,
472 required=True))
473
474 payload = form.toElement()
475 status = self._c.STATUS.EXECUTING
476 defer.returnValue((payload, status, None, None))
477
478