Mercurial > libervia-backend
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)) |