comparison libervia/backend/plugins/plugin_adhoc_dbus.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_adhoc_dbus.py@524856bd7b19
children 319a0e47dc8b
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3
4 # SAT plugin for adding D-Bus to Ad-Hoc Commands
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
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/>.
19
20 from libervia.backend.core.i18n import D_, _
21 from libervia.backend.core.constants import Const as C
22 from libervia.backend.core.log import getLogger
23
24 log = getLogger(__name__)
25 from twisted.internet import defer
26 from twisted.words.protocols.jabber import jid
27 from wokkel import data_form
28
29 try:
30 from lxml import etree
31 except ImportError:
32 etree = None
33 log.warning("Missing module lxml, please download/install it from http://lxml.de/ ."
34 "Auto D-Bus discovery will be disabled")
35 from collections import OrderedDict
36 import os.path
37 import uuid
38 try:
39 import dbus
40 from dbus.mainloop.glib import DBusGMainLoop
41 except ImportError:
42 dbus = None
43 log.warning("Missing module dbus, please download/install it, "
44 "auto D-Bus discovery will be disabled")
45
46 else:
47 DBusGMainLoop(set_as_default=True)
48
49 NS_MEDIA_PLAYER = "org.libervia.mediaplayer"
50 FD_NAME = "org.freedesktop.DBus"
51 FD_PATH = "/org/freedekstop/DBus"
52 INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable"
53 MPRIS_PREFIX = "org.mpris.MediaPlayer2"
54 CMD_GO_BACK = "GoBack"
55 CMD_GO_FWD = "GoFW"
56 SEEK_OFFSET = 5 * 1000 * 1000
57 MPRIS_COMMANDS = ["org.mpris.MediaPlayer2.Player." + cmd for cmd in (
58 "Previous", CMD_GO_BACK, "PlayPause", CMD_GO_FWD, "Next")]
59 MPRIS_PATH = "/org/mpris/MediaPlayer2"
60 MPRIS_PROPERTIES = OrderedDict((
61 ("org.mpris.MediaPlayer2", (
62 "Identity",
63 )),
64 ("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", "Title"),
73 ))
74
75 INTROSPECT_METHOD = "Introspect"
76 IGNORED_IFACES_START = (
77 "org.freedesktop",
78 "org.qtproject",
79 "org.kde.KMainWindow",
80 ) # commands in interface starting with these values will be ignored
81 FLAG_LOOP = "LOOP"
82
83 PLUGIN_INFO = {
84 C.PI_NAME: "Ad-Hoc Commands - D-Bus",
85 C.PI_IMPORT_NAME: "AD_HOC_DBUS",
86 C.PI_TYPE: "Misc",
87 C.PI_PROTOCOLS: [],
88 C.PI_DEPENDENCIES: ["XEP-0050"],
89 C.PI_MAIN: "AdHocDBus",
90 C.PI_HANDLER: "no",
91 C.PI_DESCRIPTION: _("""Add D-Bus management to Ad-Hoc commands"""),
92 }
93
94
95 class AdHocDBus(object):
96
97 def __init__(self, host):
98 log.info(_("plugin Ad-Hoc D-Bus initialization"))
99 self.host = host
100 if etree is not None:
101 host.bridge.add_method(
102 "ad_hoc_dbus_add_auto",
103 ".plugin",
104 in_sign="sasasasasasass",
105 out_sign="(sa(sss))",
106 method=self._ad_hoc_dbus_add_auto,
107 async_=True,
108 )
109 host.bridge.add_method(
110 "ad_hoc_remotes_get",
111 ".plugin",
112 in_sign="s",
113 out_sign="a(sss)",
114 method=self._ad_hoc_remotes_get,
115 async_=True,
116 )
117 self._c = host.plugins["XEP-0050"]
118 host.register_namespace("mediaplayer", NS_MEDIA_PLAYER)
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 profile_connected(self, client):
125 if dbus is not None:
126 self._c.add_ad_hoc_command(
127 client, self.local_media_cb, D_("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 )
132
133 def _dbus_async_call(self, proxy, method, *args, **kwargs):
134 """ Call a DBus method asynchronously and return a deferred
135
136 @param proxy: DBus object proxy, as returner by get_object
137 @param method: name of the method to call
138 @param args: will be transmitted to the method
139 @param kwargs: will be transmetted to the method, except for the following poped
140 values:
141 - interface: name of the interface to use
142 @return: a deferred
143
144 """
145 d = defer.Deferred()
146 interface = kwargs.pop("interface", None)
147 kwargs["reply_handler"] = lambda ret=None: d.callback(ret)
148 kwargs["error_handler"] = d.errback
149 proxy.get_dbus_method(method, dbus_interface=interface)(*args, **kwargs)
150 return d
151
152 def _dbus_get_property(self, proxy, interface, name):
153 return self._dbus_async_call(
154 proxy, "Get", interface, name, interface="org.freedesktop.DBus.Properties")
155
156
157 def _dbus_list_names(self):
158 return self._dbus_async_call(self.fd_object, "ListNames")
159
160 def _dbus_introspect(self, proxy):
161 return self._dbus_async_call(proxy, INTROSPECT_METHOD, interface=INTROSPECT_IFACE)
162
163 def _accept_method(self, method):
164 """ Return True if we accept the method for a command
165 @param method: etree.Element
166 @return: True if the method is acceptable
167
168 """
169 if method.xpath(
170 "arg[@direction='in']"
171 ): # we don't accept method with argument for the moment
172 return False
173 return True
174
175 @defer.inlineCallbacks
176 def _introspect(self, methods, bus_name, proxy):
177 log.debug("introspecting path [%s]" % proxy.object_path)
178 introspect_xml = yield self._dbus_introspect(proxy)
179 el = etree.fromstring(introspect_xml)
180 for node in el.iterchildren("node", "interface"):
181 if node.tag == "node":
182 new_path = os.path.join(proxy.object_path, node.get("name"))
183 new_proxy = self.session_bus.get_object(
184 bus_name, new_path, introspect=False
185 )
186 yield self._introspect(methods, bus_name, new_proxy)
187 elif node.tag == "interface":
188 name = node.get("name")
189 if any(name.startswith(ignored) for ignored in IGNORED_IFACES_START):
190 log.debug("interface [%s] is ignored" % name)
191 continue
192 log.debug("introspecting interface [%s]" % name)
193 for method in node.iterchildren("method"):
194 if self._accept_method(method):
195 method_name = method.get("name")
196 log.debug("method accepted: [%s]" % method_name)
197 methods.add((proxy.object_path, name, method_name))
198
199 def _ad_hoc_dbus_add_auto(self, prog_name, allowed_jids, allowed_groups, allowed_magics,
200 forbidden_jids, forbidden_groups, flags, profile_key):
201 client = self.host.get_client(profile_key)
202 return self.ad_hoc_dbus_add_auto(
203 client, prog_name, allowed_jids, allowed_groups, allowed_magics,
204 forbidden_jids, forbidden_groups, flags)
205
206 @defer.inlineCallbacks
207 def ad_hoc_dbus_add_auto(self, client, prog_name, allowed_jids=None, allowed_groups=None,
208 allowed_magics=None, forbidden_jids=None, forbidden_groups=None,
209 flags=None):
210 bus_names = yield self._dbus_list_names()
211 bus_names = [bus_name for bus_name in bus_names if "." + prog_name in bus_name]
212 if not bus_names:
213 log.info("Can't find any bus for [%s]" % prog_name)
214 defer.returnValue(("", []))
215 bus_names.sort()
216 for bus_name in bus_names:
217 if bus_name.endswith(prog_name):
218 break
219 log.info("bus name found: [%s]" % bus_name)
220 proxy = self.session_bus.get_object(bus_name, "/", introspect=False)
221 methods = set()
222
223 yield self._introspect(methods, bus_name, proxy)
224
225 if methods:
226 self._add_command(
227 client,
228 prog_name,
229 bus_name,
230 methods,
231 allowed_jids=allowed_jids,
232 allowed_groups=allowed_groups,
233 allowed_magics=allowed_magics,
234 forbidden_jids=forbidden_jids,
235 forbidden_groups=forbidden_groups,
236 flags=flags,
237 )
238
239 defer.returnValue((str(bus_name), methods))
240
241 def _add_command(self, client, adhoc_name, bus_name, methods, allowed_jids=None,
242 allowed_groups=None, allowed_magics=None, forbidden_jids=None,
243 forbidden_groups=None, flags=None):
244 if flags is None:
245 flags = set()
246
247 def d_bus_callback(client, command_elt, session_data, action, node):
248 actions = session_data.setdefault("actions", [])
249 names_map = session_data.setdefault("names_map", {})
250 actions.append(action)
251
252 if len(actions) == 1:
253 # it's our first request, we ask the desired new status
254 status = self._c.STATUS.EXECUTING
255 form = data_form.Form("form", title=_("Command selection"))
256 options = []
257 for path, iface, command in methods:
258 label = command.rsplit(".", 1)[-1]
259 name = str(uuid.uuid4())
260 names_map[name] = (path, iface, command)
261 options.append(data_form.Option(name, label))
262
263 field = data_form.Field(
264 "list-single", "command", options=options, required=True
265 )
266 form.addField(field)
267
268 payload = form.toElement()
269 note = None
270
271 elif len(actions) == 2:
272 # we should have the answer here
273 try:
274 x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
275 answer_form = data_form.Form.fromElement(x_elt)
276 command = answer_form["command"]
277 except (KeyError, StopIteration):
278 raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
279
280 if command not in names_map:
281 raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
282
283 path, iface, command = names_map[command]
284 proxy = self.session_bus.get_object(bus_name, path)
285
286 self._dbus_async_call(proxy, command, interface=iface)
287
288 # job done, we can end the session, except if we have FLAG_LOOP
289 if FLAG_LOOP in flags:
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)
293 del actions[:]
294 names_map.clear()
295 return d_bus_callback(
296 client, None, session_data, self._c.ACTION.EXECUTE, node
297 )
298 form = data_form.Form("form", title=_("Updated"))
299 form.addField(data_form.Field("fixed", "Command sent"))
300 status = self._c.STATUS.COMPLETED
301 payload = None
302 note = (self._c.NOTE.INFO, _("Command sent"))
303 else:
304 raise self._c.AdHocError(self._c.ERROR.INTERNAL)
305
306 return (payload, status, None, note)
307
308 self._c.add_ad_hoc_command(
309 client,
310 d_bus_callback,
311 adhoc_name,
312 allowed_jids=allowed_jids,
313 allowed_groups=allowed_groups,
314 allowed_magics=allowed_magics,
315 forbidden_jids=forbidden_jids,
316 forbidden_groups=forbidden_groups,
317 )
318
319 ## Local media ##
320
321 def _ad_hoc_remotes_get(self, profile):
322 return self.ad_hoc_remotes_get(self.host.get_client(profile))
323
324 @defer.inlineCallbacks
325 def ad_hoc_remotes_get(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 defer.ensureDeferred(self.host.find_by_features(
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.get_command_elt(result_elt)
348 form = data_form.findForm(command_elt, NS_MEDIA_PLAYER)
349 if form is None:
350 continue
351 mp_options = form.fields['media_player'].options
352 session_id = command_elt.getAttribute('sessionid')
353 if mp_options and session_id:
354 # we just want to discover player, so we cancel the
355 # session
356 self._c.do(client, device_jid, NS_MEDIA_PLAYER,
357 action=self._c.ACTION.CANCEL,
358 session_id=session_id)
359
360 for opt in mp_options:
361 remotes.append((device_jid_s,
362 opt.value,
363 opt.label or opt.value))
364 except Exception as e:
365 log.warning(_(
366 "Can't retrieve remote controllers on {device_jid}: "
367 "{reason}".format(device_jid=device_jid, reason=e)))
368 break
369 defer.returnValue(remotes)
370
371 def do_mpris_command(self, proxy, command):
372 iface, command = command.rsplit(".", 1)
373 if command == CMD_GO_BACK:
374 command = 'Seek'
375 args = [-SEEK_OFFSET]
376 elif command == CMD_GO_FWD:
377 command = 'Seek'
378 args = [SEEK_OFFSET]
379 else:
380 args = []
381 return self._dbus_async_call(proxy, command, *args, interface=iface)
382
383 def add_mpris_metadata(self, form, metadata):
384 """Serialise MRPIS Metadata according to MPRIS_METADATA_MAP"""
385 for mpris_key, name in MPRIS_METADATA_MAP.items():
386 if mpris_key in metadata:
387 value = str(metadata[mpris_key])
388 form.addField(data_form.Field(fieldType="fixed",
389 var=name,
390 value=value))
391
392 @defer.inlineCallbacks
393 def local_media_cb(self, client, command_elt, session_data, action, node):
394 try:
395 x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
396 command_form = data_form.Form.fromElement(x_elt)
397 except StopIteration:
398 command_form = None
399
400 if command_form is None or len(command_form.fields) == 0:
401 # root request, we looks for media players
402 bus_names = yield self._dbus_list_names()
403 bus_names = [b for b in bus_names if b.startswith(MPRIS_PREFIX)]
404 if len(bus_names) == 0:
405 note = (self._c.NOTE.INFO, D_("No media player found."))
406 defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
407 options = []
408 status = self._c.STATUS.EXECUTING
409 form = data_form.Form("form", title=D_("Media Player Selection"),
410 formNamespace=NS_MEDIA_PLAYER)
411 for bus in bus_names:
412 player_name = bus[len(MPRIS_PREFIX)+1:]
413 if not player_name:
414 log.warning(_("Ignoring MPRIS bus without suffix"))
415 continue
416 options.append(data_form.Option(bus, player_name))
417 field = data_form.Field(
418 "list-single", "media_player", options=options, required=True
419 )
420 form.addField(field)
421 payload = form.toElement()
422 defer.returnValue((payload, status, None, None))
423 else:
424 # player request
425 try:
426 bus_name = command_form["media_player"]
427 except KeyError:
428 raise ValueError(_("missing media_player value"))
429
430 if not bus_name.startswith(MPRIS_PREFIX):
431 log.warning(_("Media player ad-hoc command trying to use non MPRIS bus. "
432 "Hack attempt? Refused bus: {bus_name}").format(
433 bus_name=bus_name))
434 note = (self._c.NOTE.ERROR, D_("Invalid player name."))
435 defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
436
437 try:
438 proxy = self.session_bus.get_object(bus_name, MPRIS_PATH)
439 except dbus.exceptions.DBusException as e:
440 log.warning(_("Can't get D-Bus proxy: {reason}").format(reason=e))
441 note = (self._c.NOTE.ERROR, D_("Media player is not available anymore"))
442 defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
443 try:
444 command = command_form["command"]
445 except KeyError:
446 pass
447 else:
448 yield self.do_mpris_command(proxy, command)
449
450 # we construct the remote control form
451 form = data_form.Form("form", title=D_("Media Player Selection"))
452 form.addField(data_form.Field(fieldType="hidden",
453 var="media_player",
454 value=bus_name))
455 for iface, properties_names in MPRIS_PROPERTIES.items():
456 for name in properties_names:
457 try:
458 value = yield self._dbus_get_property(proxy, iface, name)
459 except Exception as e:
460 log.warning(_("Can't retrieve attribute {name}: {reason}")
461 .format(name=name, reason=e))
462 continue
463 if name == MPRIS_METADATA_KEY:
464 self.add_mpris_metadata(form, value)
465 else:
466 form.addField(data_form.Field(fieldType="fixed",
467 var=name,
468 value=str(value)))
469
470 commands = [data_form.Option(c, c.rsplit(".", 1)[1]) for c in MPRIS_COMMANDS]
471 form.addField(data_form.Field(fieldType="list-single",
472 var="command",
473 options=commands,
474 required=True))
475
476 payload = form.toElement()
477 status = self._c.STATUS.EXECUTING
478 defer.returnValue((payload, status, None, None))