Using LuaJIT + FFI + CoreBluetooth with an async design to automate a trivial task.
(Also published on Medium)
My office building has electronic window blinds. Push a button next to the light switches and they all go up or down. On most days, the afternoon sun hits just right to flood the office with a beautiful golden light that obliterates the contrast on my computer monitor. So someone gets up, walks over to the button, and lowers the blinds. My coworker bought a SwitchBot, which is a Bluetooth device with an app that will push a button when commanded, and affixed it to the wall of the office.
Now I won't need to get up if I either download the
official app, or use one of the numerous open source
projects. Instead,
I used this frivolous problem as an excuse to delve into FFI bindings to
Objective-C and CoreBluetooth from LuaJIT and to wrap Lua's native coroutines
into something that looks like the async
/ await
pattern from other
languages.
I chose this path because I love writing code in Lua. The language is small and the simple syntax gets out of the way. When you stop thinking about classes and inheritance and instead embrace functions as first class values, you can stretch the language in really interesting ways. Besides being wildly fast, the LuaJIT implementation of Lua has a feature I view as an underappreciated superpower: the FFI interface. This allows you to directly call into native code, without having to compile a binding layer, without almost no performance penalty. FFI extends Lua's capabilities from what is supported in the limited standard library to anything that exists on your computer.
The result of this project is switchbot.lua
. Run luajit switchbot.lua
and
the script will connect to a specific SwitchBot device and command it to push
the button. Voilà! But a lot happens under the hood. Let's take a tour through
how this all works.
This project has a high-level control script with lower-level helper modules.
switchbot.lua
: The main application logic that orchestrates the entire process.objc.lua
: A bridge to the Objective-C runtime, which is needed for controlling MacOS-specific features like CoreBluetooth.1async.lua
: A lightweight library to handle asynchronous operations cleanly and flatten out callbacks into sequential code.2
Let's look at each one in detail.
This script contains the high-level logic. Its job is to:
- Initialize the system's Bluetooth adapter.
- Scan for the specific SwitchBot device.
- Connect to it.
- Discover the correct Bluetooth "service" and "characteristic" required to send a command.
- Send the "press" command.
- Disconnect cleanly.
The script defines the unique identifiers for the SwitchBot's services and the specific commands to send. The core of the script is the main
function, which lays out the sequence of operations.
local main = a.sync(function()
-- init and scan
local delegate = makeDelegate()
local central = a.wait(Ble:init(delegate))
local peripheral = a.wait(Ble:scan(central))
peripheral.delegate = delegate -- register for peripheral callbacks
-- connect and get characteristic
a.wait(Ble:connect(central, peripheral))
local service = a.wait(Ble:discoverService(peripheral, CommandService))
local characteristic = a.wait(Ble:discoverCharacteristic(peripheral, service, CommandCharacteristic))
-- write command and wait
a.wait(Ble:write(peripheral, characteristic, PressCommand))
a.wait(Ble:sleep(delegate, 1.0)) -- wait for command to process
-- disconnect and stop
a.wait(Ble:disconnect(central, peripheral))
end)
Notice the a.sync
and a.wait
calls. This code looks synchronous, but each
of those a.wait
calls is a suspend point where the execution flow leaves the
a.sync
function and returns later to the same point when the task has been
completed (e.g. the Bluetooth peripheral has been connected).
Bluetooth communication is inherently asynchronous. You don't just call a
function and get a result back immediately. Instead, you start an operation
(like a scan) and then wait for the system to notify you with an event (like
didDiscoverPeripheral
). Handling this with traditional callbacks can lead to
deeply nested, hard-to-read code often called "callback hell."
The async.lua
module solves this by using Lua's native coroutines to create an
async/await
pattern, similar to what you might find in JavaScript or Python.
The module is built on the following concepts:
- Coroutines: Lightweight, cooperatively scheduled threads. A coroutine can be paused (yielded) and resumed later with a value.
- Futures: A "future" is a function that wraps a delayed computation.
It has a uniform signature:
function(cb) end
. The future will not block when called, but instead call the provided callback (possibly with results as callback arguments) when the work is complete. - Tasks: The
a.sync
method wraps a function in a coroutine and returns an async task. This task is a future. Calling the future starts the coroutine.
This async work is created and managed using a few key methods:
1. a.sync(function(...) end)
This function creates a coroutine-based function that can be "paused" and "resumed." The returned async task is a function that matches the function signature for a future. This means it can be "awaited" like other futures. Any arguments passed to the base function are cached until the task future is called. Care is taken to handle nil values for the variadic list of function arguments and results.
The task future has an internal poll
function, used as the future's callback
function, that will recursively resume the coroutine until it is completed. Then
the actual future callback function is called with any results. We are diligent
to always return the invoked callback (even though it does not return anything)
to finish the current stack frame and support proper tail calls. This allows for
infinite recursion.
2. a.wait(future)
This function "pauses" an async function's execution while waiting for an
asynchronous operation to complete. It's implementation is really simple,
just coroutine.yield(future)
! The yielded coroutine now waits for the async
function's poll
function to resume it with the results of a completed future.
Since coroutine.yield
returns the arguments supplied to coroutine.resume
, we
can do local val = a.wait(future)
.
3. a.wrap(function(..., cb) end)
This helper turns a traditional callback-based function into an "await-able" future. It caches any provided function arguments and returns a function that conforms to the future function signature (with just one callback argument). When called, the callback is passed to the original function. The only requirement is that the callback must be the last function argument. When this callback is called, the future completes.
In switchbot.lua
, every CoreBluetooth operation, like Ble:scan
or Ble:connect
, is wrapped. When an async function calls
a.wait(Ble:scan(central))
, the coroutine yields. When the
didDiscoverPeripheral
delegate is eventually called by the system, it invokes
a callback stored in an upvalue, resuming the coroutine, and returning the
discovered peripheral inline.
4. a.run(future)
Kick off the execution of a future from non-async context. This method doesn't block, but instead depends on some other entity to eventually complete any pending futures by calling their completion callbacks.
This is a significant difference from the async
/ await
implementations in
other languages that's worth highlighting. Traditionally, an event loop is
provided by the language. Async tasks are registered with the event loop. When
the conditions are met for a particular task to run, the event loop "wakes" the
task. It makes as much progress as possible, then yields back to the event
loop. Because the event loop is also the work scheduler, it expects to be in
control of execution. Any external libraries or frameworks need to integrate
with the language's event loop and it cannot easily be swapped out.
If we consider the event loop with scheduler design from other languages a top
down approach, then async.lua
is a bottom up design. There is no integration
between async.lua
, futures, or even the Lua interpreter and the event loop.
The only requirement is that futures get their completion callback called.
This allows for a bring-your-own-event-loop option, depending on the needs of
the particular application. For example, switchbot.lua
uses MacOS's native
NSRunLoop
.
However, for a different application, no changes would needed to async.lua
or high level tasks returned from a.sync
to switch to an event loop like
libuv
or even a while-true loop with a series of file
descriptors and a call to epoll
.
To summarize, here's a complete execution trace through a particular async operation:
- We create a
main
async task withlocal main = a.sync(function() end)
and start running it witha.run(main())
. - Within
main
, we calla.wait(Ble:scan(central))
. ThisBle:scan
function returns a future. a.wait
yields to this future, pausing themain
coroutine and passing the future back to the task.- The task takes the future and executes it, providing its own internal
poll
function as the callback. - The future tells the CoreBluetooth framework to start scanning. This happens in the background.
- Sometime later, when a device is found, the
didDiscoverPeripheral
delegate method is called. This method eventually calls the callback provided in step 4. - The task's
poll
function is now executed. It takes the result (the discovered peripheral) and callscoroutine.resume
, passing the peripheral back into the coroutine. - Execution inside
main
continues from exactly where it left off. Thea.wait
call returns the peripheral, and the script proceeds to the next line.
This model allows us to write asynchronous, event-driven code in a clean, sequential style, avoiding the complexity of nested callbacks entirely.
Note: There are a few useful async types, such as queues and channels that are part of
async.lua
, but are not used inswitchbot.lua
. I'm particularly chuffed with the channel type, that swaps out the send and receive methods with functions that either transfer a value or await a future, depending on which side gets to the channel first.
The final piece of this project is objc.lua
. Lua doesn't have a built-in
library for controlling Bluetooth. On MacOS, this is handled by the
CoreBluetooth
framework, which is written in Objective-C.
objc.lua
uses LuaJIT's FFI library to
interact directly with the Objective-C runtime
API.
This allows Lua to act as if it were a first-class Objective-C citizen,
providing access to all the native capabilities of MacOS.
Here’s a deeper look at how it works:
1. Message Passing with objc_msgSend
The core of the Objective-C language is message passing. The syntax [myObject myMethod:arg]
is compiled down to a C function call: objc_msgSend(myObject, "myMethod:", arg)
. The challenge is that objc_msgSend
is variadic and has no
fixed type signature. The types of the arguments and the return value depend on
the method being called.
The msgSend
function in objc.lua
is a wrapper that solves this. When you
call object:method(...)
in Lua, it:
- Looks up the Objective-C method on the object's class.
- Uses Objective-C runtime API functions like
class_getInstanceMethod
,method_copyReturnType
, andmethod_copyArgumentType
to determine the Objective-C type signature. - Converts the Objective-C type signature into a C function signature string
for LuaJIT's FFI on the fly (e.g.
"id (*)(id, SEL, id)"
). - Casts the generic
C.objc_msgSend
function pointer to this specific, dynamically-created function signature. - Calls the now correctly-typed function with the provided arguments.
2. Metatables for Syntactic Sugar
The module uses ffi.metatype
to attach __index
and __newindex
metamethods
to all Objective-C objects. This is what translates natural Lua syntax into
Objective-C message sends. When you write peripheral:discoverServices(...)
,
the __index
metamethod intercepts the access for "discoverServices", creates a
function that wraps an objc_msgSend
call, and executes it.
3. Creating Objective-C Classes in Lua
Additionally, objc.lua
allows us to define new Objective-C classes and
implement their methods with Lua functions. This is needed for handling
delegate callbacks. switchbot.lua
uses
objc.newClass("CentralManagerDelegate")
to create a new class and
objc.addMethod(...)
to attach Lua functions to it.
-- Create a new Objective-C class
local class = objc.newClass("CentralManagerDelegate")
-- Add a method to it, implemented by a Lua function
local scan_cb = objc.addMethod(class, "centralManager:didDiscoverPeripheral:advertisementData:RSSI:",
"v@:@@@@", didDiscoverPeripheral())
-- Create an instance of our new class
local delegate = objc.CentralManagerDelegate:alloc():init()
When we pass this delegate
object to the CBCentralManager
, the CoreBluetooth
framework holds a reference to a real Objective-C object. When a Bluetooth
event occurs, the framework sends a message to our delegate object. The
objc.lua
bridge intercepts this message and invokes the corresponding Lua
function (didDiscoverPeripheral
), seamlessly bridging the gap between the
two languages.
For switchbot.lua
, didDiscoverPeripheral
and the other CBCentralManager
delegate methods are function factories, which generate cdata
callback
functions with stored upvalues for the future completion callbacks. When
a new future is generated from a wrapped CBCentralManager
Objective-C
method, the cdata
function's associated Lua function is replaced, e.g.
scan_cb:set(didDiscoverPeripheral(cb))
. This changes the dispatching of
Objective-C's delegate method to Lua function, without having to update the
Objective-C class.
Now that we've written over 600 cumulative lines of code, it's time to push a button! There are two hardcoded constants for discovering the SwitchBot: the expected service data and the expected manufacturer data. Once a scan is started for nearby BLE devices, if we find a device advertising the expected service data, we know it is a SwitchBot, if it is also advertising the expected manufacturing data (which I believe includes the device serial number) we know it is our SwitchBot. CoreBluetooth does not provide access to a peripheral's Bluetooth address, so we need to find something uniquely identifying in the advertising data itself.
local function didDiscoverPeripheral(cb)
return function(id, sel, central, peripheral, advertisement_data, rssi)
local service_data = advertisement_data:objectForKey(CBAdvertisementDataServiceDataKey)
local mfg_data = advertisement_data:objectForKey(CBAdvertisementDataManufacturerDataKey)
if service_data and mfg_data then
local data = service_data:objectForKey(ServiceDataUuid)
if data and data:isEqualToData(ExpectedServiceData) == BOOL(true) -- peripheral is a SwitchBot
and mfg_data:isEqualToData(ExpectedMfgData) == BOOL(true) then -- peripheral is our Switchbot
NSLog("Discovered the SwitchBot")
central:stopScan() -- no more delegate callbacks
return cb and cb(peripheral:retain()) -- complete the future with the peripehral
end
end
end
end
Next we ask CoreBluetooth to discover services on the peripheral, searching for a specific service UUID. The same is done for a specific characteristic UUID. Once we have a handle to the required characteristic, we use it to write a static command for a button push action, then wait for the message confirmation.
I've discovered that if you do not remain connected until the SwitchBot's arm
has finished moving, then the action is canceled. We use an async sleep function
to wait for just a second. This is done by using a NSTimer
to call a callback
after a timeout. We conveniently reuse the single created custom class object as
a delegate for both CBCentralManager
and NSTimer
.
Now we're done.
This project is totally over engineered for this trifling task. However, the
core pattern of using a high-level scripting language for the main logic, a
foreign function interface (objc.lua
) to access platform-specific native
APIs, and an async library (async.lua
) to keep the code clean and manageable,
is a valid one. What results is a simple main script with clear logic that can
control a physical device in the real world, and save me from walking across
the room.
I made a bundler script, named bundle.lua
, to package an application and its
dependencies into a single, easy-to-run file. It transforms our set of Lua files
(switchbot.lua
, async.lua
, objc.lua
) into one self-contained, executable
LuaJIT bytecode file.
The process can be broken down into three main steps:
Step 1: Recursive Dependency Discovery
When you run luajit bundle.lua switchbot.lua
, the script first reads the
content of the entry point, switchbot.lua
.
It then uses a regular expression to find all require("...")
calls within
that file. For each module it finds (like "async"
and "objc"
), it uses the
standard package.searchpath
function to locate the corresponding file on the
filesystem (e.g., async.lua
).
If the file is found, its content is read, and the process repeats.
The bundler scans the content of async.lua
for more require
calls.
This recursive process continues until every dependency has been found
and its source code has been read into memory. This builds a complete
map of all Lua modules needed to run the application.
Step 2: Assembling the Source with a Custom Loader
Simply concatenating the files isn't enough, because the require
function
is designed to look for files on disk. The solution is to hijack the require
mechanism.
The bundler generates a single, large Lua script in memory that starts with a
"pre-loader" section. For each dependency found in Step 1 (e.g., async.lua
),
it generates a small snippet of code like this:
package.preload["async"] = function()
-- The entire content of async.lua goes here
end
The package.preload
table is a special table in Lua that require
checks before searching the filesystem. By pre-populating it, we are
telling require
: "When someone asks for the async module, don't look
for a file. Instead, just execute this function I'm giving you."
The bundler creates these package.preload
entries for every dependency
and prepends them to the original content of switchbot.lua
. The
result is one large, in-memory source file that effectively looks like
this:
-- Pre-load all dependencies
package.preload["async"] = function() ... end
package.preload["objc"] = function() ... end
-- Original main script
local a = require("async")
local objc = require("objc")
-- ... rest of switchbot.lua
When this combined script runs, the require("async")
call will find the async
key in package.preload
, execute the associated function, and cache the result
in package.loaded
, perfectly mimicking the behavior of the real require
but
without ever touching the filesystem.
Step 3: Compiling to Bytecode and Packaging
This is the final step that creates the executable file.
-
Compilation: The entire in-memory Lua source string generated in Step 2 is compiled into LuaJIT bytecode using the
string.dump()
function. This function takes Lua source code, pre-compiles it into a series of binary op-codes and arguments, and returns it as a string. -
Creating the Loader Script: The bundler prepends a small shell script "header" to the bytecode. This is a neat trick to make the file directly executable.
#!/bin/sh tail -n +4 "$0" | luajit - "$@" exit
This shell script does the following:
#!/bin/sh
: This shebang identifies it as a shell script.tail -n +4 "$0"
: Takes its own file ($0
), seeks to the 4th line, (skipping the loader script itself), and prints the rest of the file (the LuaJIT bytecode) to standard output.| luajit - "$@"
: Pipes that bytecode directly into theluajit
interpreter. The-
tells LuaJIT to read code from standard input, and"$@"
passes along any command-line arguments.
- Final Assembly: The bundler writes this shell header, followed immediately
by the raw binary bytecode, into the output file (e.g.,
switchbot.ljbc
). Finally, it makes the file executable usingchmod +x
.
The result is a single executable file that you can drop anywhere in your
$PATH
, without worrying about setting $LUA_PATH
or package.path
. This
approach only bundles Lua source code and doesn't support native C modules.
However, this is ok for switchbot.lua
, since we're exclusively using the
LuaJIT FFI for native code. The compiled bytecode file is also smaller than the
original Lua sources:
❯ wc -c *.lua
5345 async.lua
10256 objc.lua
10463 switchbot.lua
26064 total
❯ wc -c switchbot.ljbc
14554 switchbot.ljbc
Footnotes
-
Influenced and inspired by fjolnir/TLC and luapower/objc ↩
-
This module is an extended modification of shelbyd/lua-async-await ↩