Slides: systemd’s D-Bus Implementation, And Its Python asyncio Binding

TL;DR

Show

  • How does Spotify react on Next/Prev buttons? ⟶ D-Bus

  • d-feet: on session/user bus, search “spotify”, and examine object ⟶ call

  • Same with busctlwonderful commandline completion!

    $ busctl --user list | grep spotify
    $ busctl --user tree org.mpris.MediaPlayer2.spotify
    $ busctl --user call org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player Pause
    

A Big Picture

../../../../../_images/spotify.jpg ../../../../../_images/bus.jpg

Sample Client (sdbus, Blocking)

  • Simple main program ⟶ blocking

    #!/usr/bin/env python3
    
    from mpris import MPRISPlayer, MPRISApp
    import sdbus
    
    spotify_player_client = MPRISPlayer(
        bus = sdbus.sd_bus_open_user(),
        service_name = 'org.mpris.MediaPlayer2.spotify',
        object_path = '/org/mpris/MediaPlayer2')
    
    spotify_player_client.PlayPause()
    
  • Show strace output on it. Explain ppoll() usage (dispatching only one event) on non-blocking file descriptor ⟶ Blocking

Defining Interfaces, Pythonically

  • Interface definition (MPRISPlayer)

    from sdbus import DbusInterfaceCommon, dbus_method
    
    class MPRISPlayer(DbusInterfaceCommon,
                      interface_name='org.mpris.MediaPlayer2.Player'):
    
        @dbus_method()
        def PlayPause(self):
            raise NotImplementedError
    
    class MPRISApp(DbusInterfaceCommon,
                   interface_name='org.mpris.MediaPlayer2'):
    
        @dbus_method()
        def Quit(self):
            raise NotImplementedError
    
  • Quit Spotify (now via MPRISApp)

    snippet-spotify-quit
    spotify_app_client = MPRISApp(
        bus = sdbus.sd_bus_open_user(),
        service_name = 'org.mpris.MediaPlayer2.spotify',
        object_path = '/org/mpris/MediaPlayer2')
    
    spotify_app_client.Quit()
    

History/Implementations/Bindings ⟶ sdbus

Language bindings available for all languages and all implementations ⟶ confusion

For Python,

Concrete Use Case: jf-irrigation

../../../../../_images/irrigation.jpg
  • Local objects

    • Entire irrigation system, containing irrigators

    • Irrigators: sensor/switch pairs, giving water when moisture low

    • Show bin/irrigation-local.py

    • Show config

    $ ./bin/irrigation-local.py --conf configs/tomatoes-beans-file-stubs.conf
    
  • adaptation into D-Bus

Irrigation Client: Enter asyncio

First Step: Create Proxy

snippets/irrigation-client-01 (download)
#!/usr/bin/env python3

from irrigation.dbus_interfaces import DBusIrrigationSystem, DBusIrrigator

import sdbus
import asyncio


irrigation_system = DBusIrrigationSystem.new_proxy(
    bus = sdbus.sd_bus_open_user(),
    service_name = 'me.faschingbauer.IrrigationService',
    object_path = '/me/faschingbauer/IrrigationSystem')

Naive try: Use Async Definition To Block

snippets/irrigation-client-10 (download)
print(irrigation_system.GetIrrigatorNames())

Fix: Async Machinery

  • Blah event loop blah

  • strace output below

snippets/irrigation-client-20 (download)
async def main():
    print(await irrigation_system.GetIrrigatorNames())

asyncio.run(main())

Create Irrigator Proxies

snippets/irrigation-client-30 (download)
irrigators = {}

async def main():
    names = await irrigation_system.GetIrrigatorNames()
    for name in names:
        irrigators[name] = DBusIrrigator.new_proxy(
            bus = sdbus.sd_bus_open_user(),
            service_name = 'me.faschingbauer.IrrigationService',
            object_path = f'/me/faschingbauer/IrrigationSystem/{name}')

    pprint(irrigators)                                 # <-- more info wanted

D-Bus Signals

  • D-Bus Signals: events emitted from D-Bus objects

  • ⟶ opposite of method call or property read

  • ⟶ Pythonically, this can only be async for

  • Replace one client “task” (printing irrigator properties) with another (waiting for signals)

    snippets/irrigation-client-50 (download)
    async for event in irrigation_system.SwitchStateChanged:
        print(event)
    

And Parallelism?

  • async def status_loop(), report_switches_changed()

    snippets/irrigation-client-60 (download)
    async def status_loop():
        while True:
            for name, irrigator in irrigators.items():
                name = await irrigator.Name
                low = await irrigator.Low
                high = await irrigator.High
                moisture_value = await irrigator.MoistureValue 
                switch_state = await irrigator.SwitchState
    
                print(f'name {name}: low {low}, high {high}, moisture_value {moisture_value}, switch_state {switch_state}')
    
            await asyncio.sleep(1)
    
    async def report_switches_changed():
        async for event in irrigation_system.SwitchStateChanged:
            print(event)
    

Introduce asyncio.TaskGroup

snippets/irrigation-client-70 (download)
async with asyncio.TaskGroup() as tg:
    tg.create_task(status_loop())
    tg.create_task(report_switches_changed())

Keep In Mind …

  • D-Bus calls (method calls, signals, and property access) are expensive

    • tons of context switches until a call is back ⟶ picture from earlier

    • low, high, sensor value, and switch state of DBusIrrigator should better be exposed as D-Bus struct type property with those members

    • (maybe hack that)

  • Local irrigation system knows nothing about async.

    • It might implement a switch change as callback

    • … a small local-to-dbus trampoline sends the signal away

    • Not trivial: non-async functions cannot call async directly. Possibilities:

      • Local callback enqueues towards a “signal emitter task” (which is entirely async, obviously)

      • Avoid callbacks in local system (show DBusIrrigationSystem.SwitchStateChanged, and bin/irrigationd.py)

  • All in all

    • D-Bus objects/interfaces should mimic local ones as much as possible

    • But no closer

  • What else?

    • Show dbus-monitor

More asyncio