What it is
tcl-ble-osx is a Bluetooth Low Energy library for Tcl/Tk on macOS. It provides a ble command that is API-compatible with AndroWish, whose built-in ble command exists only on Android and Linux.
macOS has no such command, so Tcl code written against the AndroWish BLE API — for example the Decent Espresso de1app — cannot reach Bluetooth devices on the Mac. This library implements the API using Apple's CoreBluetooth, so that code runs unmodified.
It runs under plain tclsh (no Tk), standard Aqua wish, and undroidwish. It has been tested end-to-end against a Decent Espresso DE1 machine and an Atomax Skale scale: scan, connect, service and characteristic discovery, notification enable plus ACK, live notifications, and reads.
How it works (CoreBluetooth helper)
ble.tcl installs the ble command and presents the AndroWish event dictionaries (scan, connection, characteristic, descriptor), invoking the callback as {*}$callback $event $datadict. It has two interchangeable CoreBluetooth backends and selects one automatically:
- Subprocess helper —
bin/ble_helper.bin(a universal arm64 + x86_64 Swift binary). It speaks a tab-separated line protocol over a stdio pipe and works on every interpreter, signed or not. This is the default. - Native extension —
lib/libtclble.dylib(built fromnative/tclble.m). A loadable Tcl extension that drives CoreBluetooth in-process. Lower overhead, and the only option on iOS (iWish), where spawning a subprocess isn't allowed.
Rationale for the separate process: a loadable extension runs inside the interpreter, so its Bluetooth access takes on the interpreter's TCC (privacy) identity. That works for a signed app or on iOS, but an unsignable host such as undroidwish cannot get Bluetooth that way — and an in-process attempt there can wedge the process. The subprocess helper re-spawns itself with responsibility disclaimed (responsibility_spawnattrs_setdisclaim), so on hosts that need it the helper becomes its own TCC identity. It is also architecture- and stubs-independent.
Two macOS constraints the library handles:
- No MAC addresses. CoreBluetooth never exposes a peripheral's hardware address; it uses an opaque, host-stable
NSUUID. The library uses that UUID string as the "address" used to scan, store, and reconnect. It is stable across launches for a given Mac + peripheral pair. - Bluetooth permission (TCC). macOS attributes a Bluetooth request to the "responsible" app and needs an
NSBluetoothAlwaysUsageDescriptionusage string to prompt. The library arranges attribution so the first-run prompt appears; it is approved once and the grant persists.
Status
Tested end-to-end against a DE1 + Skale on x86_64 undroidwish, arm64 (Apple Silicon) undroidwish, iWish (Catalyst), and standard Aqua wish: scan, connect, characteristic discovery, notification enable and descriptor ACK, streaming notifications, and reads.
On macOS the default is the subprocess helper, which runs on any interpreter. The native in-process extension is opt-in and intended for signed apps and iOS.
Using it (API, env vars)
Load the package and drive it from the Tcl event loop:
package require ble
proc cb {event data} {
if {$event eq "scan"} {
puts "[dict get $data rssi] dBm [dict get $data name] [dict get $data address]"
}
}
ble scanner cb ;# start scanning; cb fires for every device
vwait forever ;# in a script (or tclsh) you must run the event loop
BLE is asynchronous, so the callback only fires while Tcl's event loop is running. wish/undroidwish enter it automatically once the script finishes, but tclsh does not — end a script with vwait forever (or vwait somevar).
Core subcommands:
ble scanner <callback> -> scanner token; starts scanning
ble start <token> -> idempotent re-scan
ble stop <token> -> stop scanning
ble connect <address> <callback> ?<reconnect>? -> connection handle (e.g. "ble1")
ble reconnect <handle> -> reconnect an existing handle
ble close <handle> -> disconnect (or stop a scanner)
ble info ?<handle>? -> open handles / info for one
ble enable <h> <suuid> <si> <cuuid> <ci> -> enable notifications
ble disable <h> <suuid> <si> <cuuid> <ci>
ble write <h> <suuid> <si> <cuuid> <ci> ?<writetype>? <data>
ble read <h> <suuid> <si> <cuuid> <ci>
ble mtu <h> ?<value>? -> negotiated MTU
ble userdata <h> ?<value>? -> per-handle scratch store
ble state -> central manager state
The callback receives $event $datadict:
event |
datadict keys |
|---|---|
scan |
address name rssi |
connection |
handle address state (connected/disconnected), mtu on connect |
characteristic (state=discovery) |
handle address suuid sinstance cuuid cinstance |
characteristic (state=connected) |
access (r read / w write-ack / c notification), value (binary), cuuid … |
descriptor (state=connected access=w) |
the notification-enable (CCCD) acknowledgement |
The sinstance/cinstance integers are assigned during discovery and echoed back; store them keyed by UUID and pass them to enable/write/read.
Environment variables:
BLE_USE_NATIVE=1— opt in to the in-process native dylib (default is the subprocess helper). Use only from a host that can legitimately hold Bluetooth in-process, such as a signed app whose Info.plist carriesNSBluetoothAlwaysUsageDescription. Loading the native dylib on a host without a usage description (plain tclsh, unsignable undroidwish) makes macOS TCC abort the process with an uncatchable SIGABRT the instant it touches CoreBluetooth.BLE_NO_NATIVE=1— force the subprocess helper (now the default; kept for backward compatibility).BLE_HELPER_NO_REEXEC=1— skip the helper's self-disclaim, so Bluetooth attributes to the launching app instead. Useful when the host bundle is itself grantable.BLE_HELPER_DEBUG— enable helper debug logging (off by default).
Building
Requires the Xcode command-line tools (swiftc).
git clone https://github.com/johnbuckman/tcl-ble-osx
cd tcl-ble-osx
./build.sh # builds + signs bin/ble_helper.bin (universal)
A prebuilt helper is included. Rebuilding locally binds the Bluetooth grant to a locally controlled signature. native/build.sh builds the in-process native dylib.
package require ble finds the package only if its directory is on Tcl's auto_path. /usr/local/lib is on the auto_path of both the system tclsh/wish and undroidwish, so a single symlink there makes it available everywhere:
ln -s /path/to/tcl-ble-osx /usr/local/lib/tcl-ble-osx
Always move the whole directory — the package locates bin/ble_helper.bin relative to ble.tcl.
First run shows a one-time macOS Bluetooth prompt; approve it and the grant persists (keyed to the helper's code signature, so rebuilding re-prompts). For distribution, sign the helper with a Developer ID and notarize the containing app so the grant is stable and Gatekeeper-friendly; keep NSBluetoothAlwaysUsageDescription in the embedded Info.plist.
Repository
- Source and issues: github.com/johnbuckman/tcl-ble-osx
- License: Tcl/Tk license (BSD-style).
Presented at EuroTcl 2026 (Vienna) — "BLE for Tcl: one Bluetooth Low Energy API, from desktop to phone, wired into the Tcl event loop." Originally written to run the Decent Espresso de1app on macOS; usable by any Tcl program that needs Bluetooth LE on the Mac.