libervia-backend/libervia/backend/plugins/plugin_adhoc_dbus.py
2026-03-31 12:57:17 +02:00

549 lines
20 KiB
Python

#!/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 <http://www.gnu.org/licenses/>.
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)