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