Introducing lsdbus: lightweight Lua client and server D-Bus bindings

D-Bus is the defacto standard IPC mechanism on Linux desktops and embedded systems. Writing services for it usually means wrestling with verbose C bindings, code generation, or heavyweight C++ machinery. lsdbus offers a lighter path: a thin Lua binding (Lua 5.* and luajit) over sd-bus, the lean D-Bus implementation from libsystemd.

The API maps naturally to Lua tables — no XML, no generated stubs. A complete service with methods, properties, and signals fits on one screen. And it's not just for writing services: it works equally well for mocking interfaces in tests, scripting command-line tools, unit testing existing D-Bus interfaces, or writing integration glue for exiting services like ConnMan or BlueZ. Let's see how.

The example: a greeter service

-- greeter.lua
local lsdb = require("lsdbus")

local greeter_if = {
   name = "org.example.Greeter",

   methods = {
      Hello = {
         { direction="in",  name="name",  type="s" },
         { direction="out", name="reply", type="s" },
         handler = function(vt, name)
            local msg = (vt.greeting or "Hello") .. ", " .. name .. "!"
            vt:emit("Greeted", msg)
            return msg
         end,
      },
   },

   properties = {
      Greeting = {
         access = "readwrite", type = "s",
         get = function(vt) return vt.greeting or "Hello" end,
         set = function(vt, val)
            vt.greeting = val
            vt:emitPropertiesChanged("Greeting")
         end,
      },
   },

   signals = {
      Greeted = { { name="message", type="s" } },
   },
}

local b = lsdb.open()
b:request_name("org.example.greeter")
local vt = lsdb.server.new(b, "/greeter", greeter_if)

b:add_signal(lsdb.SIGINT, function() b:exit_loop() end)
b:loop()

Run it:

$ lua greeter.lua

What's going on

Methods are plain Lua functions. The first argument is always the vtable object (used for storing state), followed by any D-Bus in-arguments. Return values become the D-Bus out-arguments — no type annotations needed at call time.

Properties have a get and optionally a set function. Read-only properties omit set. When a readwrite property changes it's good practice to call vt:emitPropertiesChanged(...), which fires the standard PropertiesChanged signal so any interested party is notified automatically.

Signals are declared in the interface table and emitted with vt:emit(name, args...). No manual path or interface string needed — the vtable already knows where it lives.

Clean shutdown uses b:add_signal to hook OS signals. When Ctrl-C arrives b:exit_loop() unblocks b:loop() and the process exits cleanly.

Talking to the service

Open a second terminal. lsdbus uses D-Bus introspection to build a proxy automatically:

$ lua -l lsdbus
> b = lsdbus.open()
> g = lsdbus.proxy.new(b, "org.example.greeter", "/greeter", "org.example.Greeter")
> g
srv: org.example.greeter, obj: /greeter, intf: org.example.Greeter
Methods:
  Hello (s) -> s
Properties:
  Greeting: s, readwrite
Signals:
  Greeted (s)
> g.Greeting
Hello
> g('Hello', "world")
Hello, world!
> g.Greeting = "Hi"
> g('Hello', "again")
Hi, again!

Properties are read with plain indexing and set with plain assignment. Methods are called by passing the method name as the first argument to the proxy object.

Watching signals

In a third terminal, lsdb-mon -c tails signals.

Setting Greeting also fires PropertiesChanged, visible with:

$ lsdb-mon -c
:1.278, /greeter, org.freedesktop.DBus.Properties, PropertiesChanged, {"org.example.Greeter",{Greeting="Hi"},{}}

What's next

This post only scratches the surface. Future installments will cover:

  • Type system — arrays, structs, dicts, and variants
  • Error handling — returning D-Bus errors from methods and properties, and handling them on the client side
  • Async calls and event sources — periodic timers, I/O watchers, and async method calls
  • Signal matching — subscribing to signals from other services with bus:match_signal

Source and full docs: https://github.com/lsdbus/lsdbus