#!/usr/bin/env python3 # SAT plugin for adding D-Bus to Ad-Hoc Commands # Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from collections import OrderedDict from functools import partial import os.path from typing import cast import uuid from twisted.internet import defer from twisted.internet import reactor from twisted.words.protocols.jabber import jid from tx_xmpp import data_form from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger from libervia.backend.plugins.plugin_xep_0004 import DataForm, ListSingle, Option, Text from libervia.backend.plugins.plugin_xep_0050 import ( XEP_0050, Action, AdHocCallbackData, AdHocCommand, AdHocError, Error, Note, PageData, Status, ) log = getLogger(__name__) try: from lxml import etree except ImportError: etree = None log.warning( "Missing module lxml, please download/install it from http://lxml.de/ ." "Auto D-Bus discovery will be disabled" ) try: import txdbus from txdbus import client as dbus_client except ImportError: txdbus = None dbus_client = None log.warning( "Missing module txdbus, please download/install it, auto D-Bus discovery will be " "disabled" ) NS_MEDIA_PLAYER = "org.libervia.mediaplayer" FD_NAME = "org.freedesktop.DBus" FD_PATH = "/org/freedesktop/DBus" INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable" MPRIS_PREFIX = "org.mpris.MediaPlayer2" CMD_GO_BACK = "GoBack" CMD_GO_FWD = "GoFW" SEEK_OFFSET = 5 * 1000 * 1000 MPRIS_COMMANDS = [ "org.mpris.MediaPlayer2.Player." + cmd for cmd in ("Previous", CMD_GO_BACK, "PlayPause", CMD_GO_FWD, "Next") ] MPRIS_PATH = "/org/mpris/MediaPlayer2" MPRIS_PROPERTIES = OrderedDict( ( ("org.mpris.MediaPlayer2", ("Identity",)), ( "org.mpris.MediaPlayer2.Player", ( "Metadata", "PlaybackStatus", "Volume", ), ), ) ) MPRIS_METADATA_KEY = "Metadata" MPRIS_METADATA_MAP = {"xesam:title": "Title"} INTROSPECT_METHOD = "Introspect" IGNORED_IFACES_START = ( "org.freedesktop", "org.qtproject", "org.kde.KMainWindow", ) # commands in interface starting with these values will be ignored FLAG_LOOP = "LOOP" PLUGIN_INFO = { C.PI_NAME: "Ad-Hoc Commands - D-Bus", C.PI_IMPORT_NAME: "AD_HOC_DBUS", C.PI_TYPE: "Misc", C.PI_PROTOCOLS: [], C.PI_DEPENDENCIES: ["XEP-0050"], C.PI_MAIN: "AdHocDBus", C.PI_HANDLER: "no", C.PI_DESCRIPTION: _("""Add D-Bus management to Ad-Hoc commands"""), } class AdHocDBus: def __init__(self, host): log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization") self.host = host if etree is not None: host.bridge.add_method( "ad_hoc_dbus_add_auto", ".plugin", in_sign="sasasasasasass", out_sign="(sa(sss))", method=self._ad_hoc_dbus_add_auto, async_=True, ) host.bridge.add_method( "ad_hoc_remotes_get", ".plugin", in_sign="s", out_sign="a(sss)", method=self._ad_hoc_remotes_get, async_=True, ) self._c = cast(XEP_0050, host.plugins["XEP-0050"]) host.register_namespace("mediaplayer", NS_MEDIA_PLAYER) self.session_con = None async def profile_connected(self, client): if txdbus is not None: assert dbus_client is not None if self.session_con is None: self.session_con = await dbus_client.connect(reactor, "session") self.fd_object = await self.session_con.getRemoteObject(FD_NAME, FD_PATH) self._c.register_ad_hoc_command( client, AdHocCommand( callback=self.local_media_cb, label=D_("Media Players"), node=NS_MEDIA_PLAYER, # 6 hours timeout, to avoid breaking remote, in the middle of a movie timeout=60 * 60 * 6, ), ) async def _dbus_async_call(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: - interface: name of the interface to use @return: a deferred """ return await proxy.callRemote(method, *args, **kwargs) async def _dbus_get_property(self, proxy, interface, name): return await self._dbus_async_call( proxy, "Get", interface, name, interface="org.freedesktop.DBus.Properties" ) async def _dbus_list_names(self): return await self.fd_object.callRemote("ListNames") async def _dbus_introspect(self, proxy): return await self._dbus_async_call( proxy, INTROSPECT_METHOD, interface=INTROSPECT_IFACE ) def _accept_method(self, method): """Return True if we accept the method for a command @param method: etree.Element @return: True if the method is acceptable """ if method.xpath( "arg[@direction='in']" ): # we don't accept method with argument for the moment return False return True async def _introspect(self, methods, bus_name, proxy): log.debug("introspecting path [%s]" % proxy.object_path) assert etree is not None introspect_xml = await self._dbus_introspect(proxy) el = etree.fromstring(introspect_xml) for node in el.iterchildren("node", "interface"): if node.tag == "node": new_path = os.path.join(proxy.object_path, node.get("name")) new_proxy = await self.session_con.getRemoteObject(bus_name, new_path) await self._introspect(methods, bus_name, new_proxy) elif node.tag == "interface": name = node.get("name") if any(name.startswith(ignored) for ignored in IGNORED_IFACES_START): log.debug("interface [%s] is ignored" % name) continue log.debug("introspecting interface [%s]" % name) for method in node.iterchildren("method"): if self._accept_method(method): method_name = method.get("name") log.debug("method accepted: [%s]" % method_name) methods.add((proxy.object_path, name, method_name)) def _ad_hoc_dbus_add_auto( self, prog_name, allowed_jids, allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, flags, profile_key, ): client = self.host.get_client(profile_key) return self.ad_hoc_dbus_add_auto( client, prog_name, allowed_jids, allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, flags, ) async def ad_hoc_dbus_add_auto( self, client, prog_name, allowed_jids=None, allowed_groups=None, allowed_magics=None, forbidden_jids=None, forbidden_groups=None, flags=None, ): bus_names = await self._dbus_list_names() bus_names = [bus_name for bus_name in bus_names if "." + prog_name in bus_name] if not bus_names: log.info("Can't find any bus for [%s]" % prog_name) return ("", []) bus_names.sort() for bus_name in bus_names: if bus_name.endswith(prog_name): break else: log.info(f"Can't find any command for {prog_name}") return ("", []) log.info(f"bus name found: {bus_name}") proxy = await self.session_con.getRemoteObject(bus_name, "/") methods = set() await self._introspect(methods, bus_name, proxy) if methods: self._c.add_ad_hoc_command( client, partial(self.dbus_callback, methods, bus_name, flags or set()), prog_name, allowed_jids=allowed_jids, allowed_groups=allowed_groups, allowed_magics=allowed_magics, forbidden_jids=forbidden_jids, forbidden_groups=forbidden_groups, ) return (str(bus_name), methods) async def dbus_callback( self, methods: set[tuple[str, str, str]], bus_name: str, flags: set[str], client: SatXMPPEntity, page_data: PageData, ) -> AdHocCallbackData: """Ad-hoc command for executing D-Bus methods. @param methods: List of available D-Bus methods. @param bus_name: Name of the D-Bus service. @param flags: Command execution flags. @param client: Libervia client instance. @param page_data: Ad-Hoc page data. @return: Command results. @raise AdHocError: When bad payload or internal errors occur. """ session_data = page_data.session_data names_map = session_data.setdefault("names_map", {}) if page_data.idx == 0: # It's our first request, we ask the desired new status. session_data["restart"] = False form = DataForm(title=D_("Command selection")) options = [] for path, iface, command in methods: label = command.rsplit(".", 1)[-1] value = str(uuid.uuid4()) names_map[value] = (path, iface, command) options.append(Option(value=value, label=label)) form.fields.append(ListSingle(var="command", options=options, required=True)) session_data["command_form"] = form return AdHocCallbackData(form=form, status=Status.EXECUTING) else: # We should have the form here. try: command = page_data.form.get_field("command", ListSingle).value if not command: raise KeyError except KeyError: raise AdHocError(Error.BAD_PAYLOAD) if command not in names_map: raise AdHocError(Error.BAD_PAYLOAD, text=f"Invalid command: {command!r}.") path, iface, command = names_map[command] assert self.session_con is not None proxy = await self.session_con.getRemoteObject(bus_name, path) await self._dbus_async_call(proxy, command, interface=iface) # Job done, we can end the session, except if we have FLAG_LOOP. if FLAG_LOOP in flags: return AdHocCallbackData( form=session_data["command_form"], status=Status.EXECUTING, notes=[Note(text=D_("Command sent."))], ) else: return AdHocCallbackData( status=Status.COMPLETED, notes=[Note(text=D_("Command sent."))] ) ## Local media ## def _ad_hoc_remotes_get(self, profile): return self.ad_hoc_remotes_get(self.host.get_client(profile)) async def ad_hoc_remotes_get( self, client: SatXMPPEntity ) -> list[tuple[str, str, str]]: """Retrieve available remote media controlers in our devices. @return: list of devices with: - entity full jid - device name - device label """ found_data = await defer.ensureDeferred( self.host.find_by_features( 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 = await self._c.list_commands(client, device_jid) for cmd in cmd_list: if cmd.nodeIdentifier == NS_MEDIA_PLAYER: try: result_elt = await self._c.do( client, device_jid, NS_MEDIA_PLAYER, timeout=5 ) command_elt = self._c.get_command_elt(result_elt) form = data_form.findForm(command_elt, NS_MEDIA_PLAYER) if form is None: continue 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=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( _( "Can't retrieve remote controllers on {device_jid}: " "{reason}".format(device_jid=device_jid, reason=e) ) ) break return remotes async def do_mpris_command(self, proxy, command): iface, command = command.rsplit(".", 1) if command == CMD_GO_BACK: command = "Seek" args = [-SEEK_OFFSET] elif command == CMD_GO_FWD: command = "Seek" args = [SEEK_OFFSET] else: args = [] return await self._dbus_async_call(proxy, command, *args, interface=iface) def add_mpris_metadata(self, form: DataForm, metadata: dict): """Serialise MRPIS Metadata according to MPRIS_METADATA_MAP""" for mpris_key, name in MPRIS_METADATA_MAP.items(): if mpris_key in metadata: value = str(metadata[mpris_key]) form.fields.append(Text(type="fixed", var=name, value=value)) async def local_media_cb( self, client: SatXMPPEntity, page_data: PageData, ) -> AdHocCallbackData: """Ad-hoc command for controlling media players via D-Bus. @param client: Libervia client instance. @param page_data: Ad-Hoc page data. @return: Command results. @raise AdHocError: Something went wrong. """ assert txdbus is not None if page_data.idx == 0: # root request, we looks for media players bus_names = await self._dbus_list_names() bus_names = [b for b in bus_names if b.startswith(MPRIS_PREFIX)] if len(bus_names) == 0: return AdHocCallbackData( status=Status.COMPLETED, notes=[Note(text=D_("No media player found."))], ) options = [] form = DataForm( title=D_("Media Player Selection"), namespace=NS_MEDIA_PLAYER, ) for bus in bus_names: player_name = bus[len(MPRIS_PREFIX) + 1 :] if not player_name: log.warning(_("Ignoring MPRIS bus without suffix.")) continue options.append(Option(value=bus, label=player_name)) form.fields.append( ListSingle(var="media_player", options=options, required=True) ) return AdHocCallbackData(form=form, status=Status.EXECUTING) else: # player request try: bus_name = page_data.session_data["bus_name"] except KeyError: try: bus_name = page_data.form.get_field("media_player", ListSingle).value if not bus_name: raise KeyError except KeyError: raise AdHocError( Error.BAD_PAYLOAD, text=D_("missing media_player value") ) else: page_data.session_data["bus_name"] = bus_name if not bus_name.startswith(MPRIS_PREFIX): log.warning( "Media player ad-hoc command trying to use non MPRIS bus. Hack " f"attempt? Refused bus: {bus_name}" ) raise AdHocError(Error.BAD_PAYLOAD, text=D_("Invalid player name.")) assert self.session_con is not None try: proxy = await self.session_con.getRemoteObject( bus_name, MPRIS_PATH, "org.mpris.MediaPlayer2.Player" ) except Exception as e: log.exception(f"Can't get D-Bus proxy.") raise AdHocError( Error.ITEM_NOT_FOUND, D_("Media player is not available anymore") ) try: command = page_data.form.get_field("command", ListSingle).value if not command: raise KeyError except KeyError: pass else: await self.do_mpris_command(proxy, command) # we construct the remote control form form = DataForm( title=D_("Media Player Selection"), ) for iface, properties_names in MPRIS_PROPERTIES.items(): for name in properties_names: try: value = await self._dbus_get_property(proxy, iface, name) except Exception as e: log.warning(f"Can't retrieve attribute {name}: {e}") continue if name == MPRIS_METADATA_KEY: self.add_mpris_metadata(form, value) else: form.fields.append(Text(type="fixed", var=name, value=str(value))) commands = [ Option(value=c, label=c.rsplit(".", 1)[1]) for c in MPRIS_COMMANDS ] form.fields.append(ListSingle(var="command", options=commands, required=True)) return AdHocCallbackData(form=form, status=Status.EXECUTING)