From 0659996c48f60cc14ff6e356fa688a729affad94 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 16 Aug 2020 01:57:30 +0200 Subject: [PATCH] Added DBus integration [closes #141] --- docs/source/conf.py | 3 + platypush/backend/dbus.py | 86 ++++++++++++++++++ platypush/message/event/zeroconf.py | 3 +- platypush/plugins/dbus.py | 130 ++++++++++++++++++++++++++++ platypush/plugins/zeroconf.py | 3 + requirements.txt | 3 + setup.py | 2 + 7 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 platypush/backend/dbus.py create mode 100644 platypush/plugins/dbus.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 5a87d49897..502811e820 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -254,6 +254,9 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', 'paramiko', 'luma', 'zeroconf', + 'dbus', + 'gi', + 'gi.repository', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/backend/dbus.py b/platypush/backend/dbus.py new file mode 100644 index 0000000000..006c2caaac --- /dev/null +++ b/platypush/backend/dbus.py @@ -0,0 +1,86 @@ +from typing import Union + +# noinspection PyPackageRequirements,PyUnresolvedReferences +from gi.repository import GLib + +import dbus +import dbus.service +import dbus.mainloop.glib + +from platypush.backend import Backend +from platypush.context import get_bus +from platypush.message import Message +from platypush.message.event import Event +from platypush.message.request import Request +from platypush.utils import run + + +# noinspection PyPep8Naming +class DBusService(dbus.service.Object): + @classmethod + def _parse_msg(cls, msg: Union[dict, list]): + import json + return Message.build(json.loads(json.dumps(msg))) + + @dbus.service.method('org.platypush.MessageBusInterface', in_signature='a{sv}', out_signature='v') + def Post(self, msg: dict): + """ + This method accepts a message as a dictionary (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 + elif isinstance(msg, Event): + get_bus().post(msg) + return 0 + + +class DbusBackend(Backend): + """ + This backend acts as a proxy that receives messages (requests or events) on the DBus and forwards them to the + application bus. + + The name of the messaging interface exposed by Platypush is ``org.platypush.MessageBusInterface`` and it exposes + ``Post`` method, which accepts a dictionary representing a valid Platypush message (either a request or an event) + and either executes it or forwards it to the application bus. + + Requires: + + * **dbus-python** (``pip install dbus-python``) + + """ + + def __init__(self, bus_name='org.platypush.Bus', service_path='/MessageService', *args, **kwargs): + """ + :param bus_name: Name of the bus where the application will listen for incoming messages (default: + ``org.platypush.Bus``). + :param service_path: Path to the service exposed by the app (default: ``/MessageService``). + """ + super().__init__(*args, **kwargs) + self.bus_name = bus_name + self.service_path = service_path + + def run(self): + super().run() + + # noinspection PyUnresolvedReferences + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SessionBus() + name = dbus.service.BusName(self.bus_name, bus) + srv = DBusService(bus, self.service_path) + + loop = GLib.MainLoop() + # noinspection PyProtectedMember + self.logger.info('Starting DBus main loop - bus name: {}, service: {}'.format(name._name, srv._object_path)) + loop.run() + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/event/zeroconf.py b/platypush/message/event/zeroconf.py index b811966340..d3c9a60862 100644 --- a/platypush/message/event/zeroconf.py +++ b/platypush/message/event/zeroconf.py @@ -1,4 +1,5 @@ import enum +from typing import Optional from platypush.message.event import Event @@ -11,7 +12,7 @@ class ZeroconfEventType(enum.Enum): class ZeroconfEvent(Event): def __init__(self, service_event: ZeroconfEventType, service_type: str, service_name: str, - service_info: dict, *args, **kwargs): + service_info: Optional[dict] = None, *args, **kwargs): super().__init__(*args, service_event=service_event.value, service_type=service_type, service_name=service_name, service_info=service_info, **kwargs) diff --git a/platypush/plugins/dbus.py b/platypush/plugins/dbus.py new file mode 100644 index 0000000000..786aca2470 --- /dev/null +++ b/platypush/plugins/dbus.py @@ -0,0 +1,130 @@ +import enum +import json +from typing import Set, Dict, Optional +from xml.etree import ElementTree + +import dbus + +from platypush.plugins import Plugin, action + + +class BusType(enum.Enum): + SYSTEM = 'system' + SESSION = 'session' + + +class DbusPlugin(Plugin): + """ + Plugin to interact with DBus. + + Requires: + + * **dbus-python** (``pip install dbus-python``) + + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + @staticmethod + def _get_bus_names(bus: dbus.Bus) -> Set[str]: + return set([str(name) for name in bus.list_names() if not name.startswith(':')]) + + @classmethod + def path_names(cls, bus: dbus.Bus, service: str, object_path='/', paths=None, service_dict=None): + if not paths: + paths = {} + + paths[object_path] = {} + obj = bus.get_object(service, object_path) + interface = dbus.Interface(obj, 'org.freedesktop.DBus.Introspectable') + 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'])) + cls.path_names(bus, service, new_path, paths) + else: + if not object_path: + object_path = '/' + function_dict = {} + for func in list(child): + if func.tag not in function_dict.keys(): + function_dict[func.tag] = [] + function_dict[func.tag].append(func.attrib['name']) + + if function_dict: + paths[object_path][child.attrib['name']] = function_dict + + if not service_dict: + service_dict = {} + if paths: + service_dict[service] = paths + + return service_dict + + @action + def query(self, service: Optional[str] = None, system_bus: bool = True, session_bus: bool = True) \ + -> 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 system_bus: Query the system bus (default: True). + :param session_bus: Query the session bus (default: True). + :return: A ``{service_name -> {properties}}`` mapping. + """ + busses = {} + response = {} + + if system_bus: + busses['system'] = dbus.SystemBus() + if session_bus: + busses['session'] = dbus.SessionBus() + + for bus_name, bus in busses.items(): + services = {} + service_names = self._get_bus_names(bus) + + if not service: + for srv in service_names: + services[srv] = self.path_names(bus, srv) + elif service in service_names: + services[service] = self.path_names(bus, service) + + response[bus_name] = services + + return response + + @action + def execute(self, service: str, path: str, method_name: str, args: Optional[list] = None, + interface: Optional[str] = None, bus_type: str = BusType.SESSION.value): + """ + Execute a method exposed on DBus. + + :param service: Service/bus name (e.g. ``org.platypush.Bus``). + :param path: Object path (e.g. ``/MessageService``). + :param method_name: Method name (e.g. ``Post``). + :param args: Arguments to be passed to the method, depending on the method signature. + :param interface: Interface name (e.g. ``org.platypush.MessageBusInterface``). + :param bus_type: Bus type (supported: ``system`` and ``session`` - default: ``session``). + :return: Return value of the executed method. + """ + if not args: + args = [] + + kwargs = {} + if interface: + kwargs['dbus_interface'] = interface + + bus_type = BusType(bus_type) + bus = dbus.SessionBus() if bus_type == BusType.SESSION else dbus.SystemBus() + obj = bus.get_object(bus_name=service, object_path=path) + ret = getattr(obj, method_name)(*args, **kwargs) + return json.loads(json.dumps(ret)) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/zeroconf.py b/platypush/plugins/zeroconf.py index 2c39b3f0f3..f8ee50960e 100644 --- a/platypush/plugins/zeroconf.py +++ b/platypush/plugins/zeroconf.py @@ -88,6 +88,9 @@ class ZeroconfPlugin(Plugin): discovery will loop forever and generate events upon service changes. :return: A ``service_type -> [service_names]`` mapping. Example:: + + .. code-block:: json + { "host1._platypush-http._tcp.local.": { "type": "_platypush-http._tcp.local.", diff --git a/requirements.txt b/requirements.txt index 24aa82d8e8..9b50b300c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -276,3 +276,6 @@ croniter # Support for luma.oled # git+https://github.com/rm-hull/luma.oled + +# Support for DBus integration +# python-dbus \ No newline at end of file diff --git a/setup.py b/setup.py index a5507f33c4..15ab7fd0d4 100755 --- a/setup.py +++ b/setup.py @@ -319,5 +319,7 @@ setup( 'clipboard': ['pyperclip'], # Support for luma.oled display drivers 'luma-oled': ['luma.oled @ git+https://github.com/rm-hull/luma.oled'], + # Support for DBus integration + 'dbus': ['dbus-python'], }, )