platypush/platypush/plugins/dbus/__init__.py
Fabio Manganiello c3337ccc6c
[#311] Docs deps autogen sphinx plugin.
Added an `add_dependencies` plugin to the Sphinx build process that
parses the manifest files of the scanned backends and plugins and
automatically generates the documentation for the required dependencies
and triggered events.

This means that those dependencies are no longer required to be listed
in the docstring of the class itself.

Also in this commit:

- Black/LINT for some integrations that hadn't been touched in a long
  time.

- Deleted some leftovers from previous refactors (deprecated
  `backend.mqtt`, `backend.zwave.mqtt`, `backend.http.request.rss`).

- Deleted deprecated `inotify` backend - replaced by `file.monitor` (see
  #289).
2023-09-24 17:00:08 +02:00

480 lines
16 KiB
Python

import enum
import json
from typing import Set, Dict, Optional, Iterable, Callable, Union
from gi.repository import GLib # type: ignore
from pydbus import SessionBus, SystemBus
from pydbus.bus import Bus
from defusedxml import ElementTree
from platypush.context import get_bus
from platypush.message import Message
from platypush.message.event import Event
from platypush.message.event.dbus import DbusSignalEvent
from platypush.message.request import Request
from platypush.plugins import RunnablePlugin, action
from platypush.schemas.dbus import DbusSignalSchema
from platypush.utils import run
_default_service_name = 'org.platypush.Bus'
_default_service_path = '/'
_default_interface_name = 'org.platypush.Bus'
class BusType(enum.Enum):
SYSTEM = 'system'
SESSION = 'session'
class DBusService:
"""
<node>
<interface name="org.platypush.Bus">
<method name="Post">
<arg type="s" name="msg" direction="in"/>
<arg type="s" name="response" direction="out"/>
</method>
</interface>
</node>
"""
@classmethod
def _parse_msg(cls, msg: Union[str, dict]) -> dict:
return Message.build(json.loads(json.dumps(msg)))
def Post(self, msg: dict):
"""
This method accepts a message as a JSON object
(either representing a valid request or an event) and either
executes it (request) or forwards it to the application bus (event).
:param msg: Request or event, as a dictionary.
:return: The return value of the request, or 0 if the message is an event.
"""
msg = self._parse_msg(msg)
if isinstance(msg, Request):
ret = run(msg.action, **msg.args)
if ret is None:
ret = '' # DBus doesn't like None return types
return ret
if isinstance(msg, Event):
get_bus().post(msg)
return 0
class DbusPlugin(RunnablePlugin):
"""
Plugin to interact with DBus.
This plugin can be used for the following tasks:
* It can expose a D-Bus interface that other applications can use to push messages
to Platypush (either action requests or events) serialized in JSON format.
You can disable this listener by setting ``service_name`` to ``null`` in your
configuration. If the D-Bus Platypush interface is enabled then you can push
Platypush events and requests in JSON format from another application or script
by specifying:
* The D-Bus service (default: ``org.platypush.Bus``)
* The D-Bus interface (default: ``org.platypush.Bus``)
* The D-Bus path (default: ``/``)
* The D-Bus method (``Post``)
* The Platypush JSON payload (first argument of the request). Format:
``{"type": "request", "action": "module.action", "args": {...}}``
* It can subscribe to multiple D-Bus signals, and it triggers a ``DbusSignalEvent``
when an event is received (signal filters should be specified in the ``signals``
configuration).
* It can be used to query and inspect D-Bus objects through the :meth:`.query` method.
* It can be used to execute methods exponsed by D-Bus objects through the
:meth:`.execute` method.
"""
def __init__(
self,
signals: Optional[Iterable[dict]] = None,
service_name: Optional[str] = _default_service_name,
service_path: Optional[str] = _default_service_path,
**kwargs,
):
"""
:param signals: Specify this if you want to subscribe to specific DBus
signals. Structure:
.. schema:: dbus.DbusSignalSchema(many=True)
For example, to subscribe to all the messages on the session bus:
.. code-block:: yaml
dbus:
signals:
- bus: session
:param service_name: Name of the D-Bus service where Platypush will listen
for new messages (requests and events). Set to null if you want to disable
message execution over D-Bus for Platypush (default: ``org.platypush.Bus``).
:param service_path: The path of the D-Bus message listener. Set to null
if you want to disable message execution over D-Bus for Platypush
(default: ``/``).
"""
super().__init__(**kwargs)
self._system_bus = SystemBus()
self._session_bus = SessionBus()
self._loop = None
self._signals = DbusSignalSchema().load(signals or [], many=True)
self._signal_handlers = [
self._get_signal_handler(**signal) for signal in self._signals
]
self.service_name = service_name
self.service_path = service_path
@staticmethod
def _get_signal_handler(bus: str, **_) -> Callable:
def handler(sender, path, interface, signal, params):
get_bus().post(
DbusSignalEvent(
bus=bus,
signal=signal,
path=path,
interface=interface,
sender=sender,
params=params,
)
)
return handler
def _get_bus(self, bus_type: Union[str, BusType]) -> Bus:
if isinstance(bus_type, str):
bus_type = BusType(bus_type.lower())
return self._system_bus if bus_type == BusType.SYSTEM else self._session_bus
def _init_signal_listeners(self):
for i, signal in enumerate(self._signals):
handler = self._signal_handlers[i]
bus = self._get_bus(signal['bus'])
bus.subscribe(
signal_fired=handler,
signal=signal.get('signal'),
sender=signal.get('sender'),
object=signal.get('path'),
iface=signal.get('interface'),
)
def _init_service(self):
if not (self.service_name and self.service_path):
return
self._session_bus.publish(
self.service_name,
('/', DBusService()),
)
def main(self):
self._init_signal_listeners()
self._init_service()
self._loop = GLib.MainLoop()
self._loop.run()
def stop(self):
self._should_stop.set()
if self._loop:
self._loop.quit()
self._loop = None
self.logger.info('Stopped D-Bus main loop')
@staticmethod
def _get_bus_names(bus: Bus) -> Set[str]:
return {str(name) for name in bus.dbus.ListNames() if not name.startswith(':')}
def path_names(
self, bus: Bus, service: str, object_path='/', paths=None, service_dict=None
):
if paths is None:
paths = {}
if service_dict is None:
service_dict = {}
paths[object_path] = {}
try:
obj = bus.get(service, object_path)
interface = obj['org.freedesktop.DBus.Introspectable']
except GLib.GError as e:
self.logger.warning(
f'Could not inspect D-Bus object {service}, path={object_path}: {e}'
)
return {}
except KeyError as e:
self.logger.warning(
f'Could not get interfaces on the D-Bus object {service}, path={object_path}: {e}'
)
return {}
xml_string = interface.Introspect()
root = ElementTree.fromstring(xml_string)
for child in root:
if child.tag == 'node':
if object_path == '/':
object_path = ''
new_path = '/'.join((object_path, child.attrib['name']))
self.path_names(
bus, service, new_path, paths, service_dict=service_dict
)
else:
if not object_path:
object_path = '/'
functions_dict = {}
for func in list(child):
function_dict = {'name': func.attrib['name']}
for arg in list(func):
if arg.tag != 'arg':
continue
function_dict['args'] = function_dict.get('args', [])
function_dict['args'].append(arg.attrib)
if func.tag not in functions_dict:
functions_dict[func.tag] = []
functions_dict[func.tag].append(function_dict)
if functions_dict:
paths[object_path][child.attrib['name']] = functions_dict
if paths:
service_dict[service] = paths
return service_dict
@action
def query(
self, service: Optional[str] = None, bus=tuple(t.value for t in BusType)
) -> Dict[str, dict]:
"""
Query DBus for a specific service or for the full list of services.
:param service: Service name (default: None, query all services).
:param bus: Which bus(ses) should be queried (default: both ``system`` and ``session``).
:return: A ``{service_name -> {properties}}`` mapping. Example:
.. code-block:: json
"session": {
"org.platypush.Bus": {
"/": {
"org.freedesktop.DBus.Properties": {
"method": [
{
"name": "Get",
"args": [
{
"type": "s",
"name": "interface_name",
"direction": "in"
},
{
"type": "s",
"name": "property_name",
"direction": "in"
},
{
"type": "v",
"name": "value",
"direction": "out"
}
]
},
{
"name": "GetAll",
"args": [
{
"type": "s",
"name": "interface_name",
"direction": "in"
},
{
"type": "a{sv}",
"name": "properties",
"direction": "out"
}
]
},
{
"name": "Set",
"args": [
{
"type": "s",
"name": "interface_name",
"direction": "in"
},
{
"type": "s",
"name": "property_name",
"direction": "in"
},
{
"type": "v",
"name": "value",
"direction": "in"
}
]
}
],
"signal": [
{
"name": "PropertiesChanged",
"args": [
{
"type": "s",
"name": "interface_name"
},
{
"type": "a{sv}",
"name": "changed_properties"
},
{
"type": "as",
"name": "invalidated_properties"
}
]
}
]
},
"org.freedesktop.DBus.Introspectable": {
"method": [
{
"name": "Introspect",
"args": [
{
"type": "s",
"name": "xml_data",
"direction": "out"
}
]
}
]
},
"org.freedesktop.DBus.Peer": {
"method": [
{
"name": "Ping"
},
{
"name": "GetMachineId",
"args": [
{
"type": "s",
"name": "machine_uuid",
"direction": "out"
}
]
}
]
},
"org.platypush.Bus": {
"method": [
{
"name": "Post",
"args": [
{
"type": "s",
"name": "msg",
"direction": "in"
},
{
"type": "s",
"name": "response",
"direction": "out"
}
]
}
]
}
}
}
}
"""
busses = {}
response = {}
if isinstance(bus, str):
bus = (bus,)
if BusType.SYSTEM.value in bus:
busses['system'] = self._system_bus
if BusType.SESSION.value in bus:
busses['session'] = self._session_bus
for bus_name, bus in busses.items():
services = {}
service_names = self._get_bus_names(bus)
if not service:
for srv in service_names:
services.update(self.path_names(bus, srv))
elif service in service_names:
services.update(self.path_names(bus, service))
response[bus_name] = services
return response
@action
def execute(
self,
service: str,
interface: str,
method_name: str,
bus: str = BusType.SESSION.value,
path: str = '/',
args: Optional[list] = None,
):
"""
Execute a method exposed on DBus.
:param service: D-Bus service name.
:param interface: D-Bus nterface name.
:param method_name: Method name.
:param bus: Bus type. Supported: ``system`` and ``session`` (default: ``session``).
:param path: Object path.
:param args: Arguments to be passed to the method, depending on the method signature.
:return: Return value of the executed method.
"""
if not args:
args = []
bus = self._get_bus(bus)
obj = bus.get(service, path)[interface]
method = getattr(obj, method_name, None)
assert method, (
f'No such method exposed by service={service}, '
f'interface={interface}: {method_name}'
)
# Normalize any lists/dictionaries to JSON strings
for i, arg in enumerate(args):
if isinstance(arg, (list, tuple, dict)):
args[i] = json.dumps(arg)
ret = method(*args)
try:
ret = json.loads(json.dumps(ret))
except Exception as e:
self.logger.debug(e)
return ret
# vim:sw=4:ts=4:et: