Building a D-Bus service in Python

Building a D-Bus service in Python

I'm a big fan of D-Bus for implementing services and their clients on Linux. According to its website:

D-Bus is an inter-process communication mechanism—a medium for local communication between processes running on the same host.

Essentially, clients connect to the system- or session-level bus where they can then communicate with associated services by calling their methods or listening to their signals, which looks something like:

D-Bus_method_invocation.svg.png

This is the approach I used with sessiond and one I decided to implement in my monitor brightness controller, lighten—written in Python.

First, let's address why D-Bus is an appropriate choice for such systems:

  • Using a standardized, full-featured protocol means we don't have to reinvent a client-server communication paradigm.

  • Its ubiquity on the Linux desktop means access to a greater ecosystem of tools, language bindings, and learning resources.

Now, let's go into detail about the D-Bus architecture and explore the how of building a service in Python!

D-Bus architecture

There are typically at least two D-Bus buses running in a given Linux environment. One operates at the system-level, independent of logged-in users. This is where system services, such as systemd and systemd-logind interact. Additionally, each user gets their own bus, where session-level services communicate.

When a service connects to one of these buses, it registers using a well-known name. This is the address by which other services or clients on the bus refer to it. These names conventionally look like reversed domain names. In the case of lighten, this name is: com.github.jcrd.lighten.

Services expose communication endpoints called objects, identified by an object path like /com/github/jcrd/lighten. These objects implement interfaces, where methods and signals are defined. lighten has two such interfaces:

  • com.github.jcrd.lighten.Backlight, with methods for interacting with the monitor's backlight;

  • com.github.jcrd.lighten.Sensor, with a method to get sensor data.

Each of these interfaces is implemented by a single object, but this isn't always the case. For example, sessiond has an interface for audio sinks, with an object created for each existing audio device.

Interfaces define methods with signatures denoting the type of in and out parameters. These parameters can be thought of as arguments and return values, respectively. Supported types include:

  • integers

  • booleans

  • strings

  • arrays

  • dictionaries

See this document for a complete list.

Methods are 1:1 modes of communication, returning data to the requesting client.

Interfaces also define signals with type signatures. Signals are 1:n modes of communication, publishing data to all subscribed clients.

This overview grants an understanding of the flow of data through the D-Bus architecture sufficient to create our own service. Let's go!

Python implementation

There are numerous Python libraries for building D-Bus services. Perhaps historically the most popular, dbus-python now describes itself as a legacy API and advises the use of alternatives.

I think the best alternative is GDBus, the D-Bus subsystem integrated into GLib. I used the C library in sessiond. It's usable in Python via PyGObject which also provides access to much of GLib itself.

The biggest hurdle was the lack of Python-specific documentation. Thankfully, I found this discussion, wherein user J Arun Mani (thanks!) provides working examples of both client and server code.

Follow this tutorial to install PyGObject and let's get to work!

To build out the D-Bus service in Python, first import the relevant libraries:

from gi.repository import Gio, GLib

Next, use inline XML to specify the service's interfaces:

xml = f"""
<node>
  <interface name='com.github.jcrd.lighten.Backlight'>
      <method name='SetBrightness'>
          <arg name='value' type='u' direction='in'/>
          <arg name='success' type='b' direction='out'/>
      </method>
      <method name='AddBrightness'>
          <arg name='value' type='i' direction='in'/>
          <arg name='success' type='b' direction='out'/>
      </method>
      <method name='RestoreBrightness'>
          <arg name='success' type='b' direction='out'/>
      </method>
      <method name='GetBrightness'>
          <arg name='value' type='i' direction='out'/>
      </method>
  </interface>
</node>
"""

This page provides more information about the D-Bus Introspection XML.

Every interface needs an accompanying interface handler function to dispatch Python code based on the called D-Bus method. The handler's function signature is:

def handler(self, conn, sender, path, iname, method, params, invo):

The useful arguments are:

  • method: the name of the called method;

  • params: the parameters to the called method;

  • invo: the invocation object used to return values.

params must be converted to Python types and accessed as an array. Get the first D-Bus method parameter with:

params.unpack()[0]

Return values must be provided to invo as a GLib variant:

invo.return_value(GLib.Variant("(b)", (True,)))

The D-Bus signature, (b), is always a structure in this use case, so parentheses are required. Refer to the Signature Encoding table here to determine the appropriate type character.

To wire everything together, three different bus handler functions can be defined. These are called when:

  1. the service connects to its bus;

  2. the service is assigned its name;

  3. the service loses its name.

There are some nuances in the operation of these handlers. The first two serve nearly the same purpose, but it could be the case that bus connection succeeds yet a service with the same name already exists, so the second handler might not be called. It is important to note that a service can replace one with the same name provided the proper initialization flag. It's in this case that the replaced service's third handler is called. Disconnection from the bus itself would also trigger it.

Discretion is advised in choosing which to fully implement. For sessiond, which must exhibit robust behavior, I implemented handlers 2 and 3, forgoing 1 entirely. However, for lighten, I only used the first!

The general approach is to register objects when the service is connected and available, and, if appropriate, tear them down should the service become unavailable.

I recommend encapsulating all the service logic in a Python class, like so:

class Service:
    def __init__(self):
        # Parse the XML interfaces:
        self.node = Gio.DBusNodeInfo.new_for_xml(xml)
        # Reference the GLib main loop:
        self.loop = GLib.MainLoop()

        # Connect to a bus:
        self.owner_id = Gio.bus_own_name(
            # Specify connection to the session bus:
            Gio.BusType.SESSION,
            # Set the well-known name:
            "com.github.jcrd.lighten",
            # Provide any flags
            # (for example, to allow replacement):
            Gio.BusNameOwnerFlags.NONE,
            # Provide handler 1 (defined below):
            self.on_bus_acquired,
            # Provide handler 2:
            None,
            # Provide handler 3:
            None,
        )

    def __del__(self):
        # Disconnect when the class is destroyed:
        Gio.bus_unown_name(self.owner_id)

    # Define handler 1:
    def on_bus_acquired(self, conn, name):
        # Register an object:
        conn.register_object(
            # Set the object path:
            "/com/github/jcrd/lighten",
            # Specify the interface via index
            # (as defined above in the XML):
            self.node.interfaces[0],
            # Provide the interface handler function:
            self.on_handle_backlight,
            None,
            None,
        )

  # Define the interface handler function
  # (abbreviated here with ...):
  def on_handle_backlight(self, conn, sender, path, iname, method, params, invo):
    if method == "SetBrightness":
        v = params.unpack()[0]
        r = ...(v)
        invo.return_value(GLib.Variant("(b)", (r,)))
    elif method == "AddBrightness":
        ...

Now, instantiate the class and run the D-Bus service with:

Service().loop.run()

Finally, use a tool such as d-feet to admire the fruits of your labor!

Screenshot from 2022-09-13 01-03-00.png