A Bluetooth Low Energy ble command for Tcl/Tk on macOS, API-compatible with
AndroWish's built-in ble command.
Part of a collection of Tcl/Tk projects — see this project's detail page for a fuller write-up.
AndroWish and undroidwish ship a ble command on Android and Linux; macOS has none,
so Tcl code written against the AndroWish BLE API (for example the
Decent Espresso de1app) cannot talk
to Bluetooth devices there. This library implements the API using Apple's
CoreBluetooth, so that code runs unmodified on the Mac.
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/characteristic discovery, notification enable + ACK,
live notifications, and reads.
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 loopNote: BLE is asynchronous, so the callback only fires while Tcl's event loop is running.
wish/undroidwish enter it automatically once the script finishes, buttclshdoes not — in a script end withvwait forever(orvwait somevar), and at an interactivetclshprompt runvwait forever(Ctrl-C to stop) afterble scanner, otherwise nothing prints.
Presented at EuroTcl 2026 (Vienna) — "BLE for Tcl: one Bluetooth Low Energy API, from desktop to phone, wired into the Tcl event loop." The slides cover GATT basics, the API, the CoreBluetooth backend, and driving a Decent Espresso DE1 over BLE.
- BLE-for-Tcl.pdf — view in the browser
- BLE-for-Tcl.pptx — editable source
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
backends and selects one automatically:
your Tcl ──ble …──▶ ble.tcl ──┬─▶ bin/ble_helper.bin (subprocess, Swift) ─▶ CoreBluetooth
│ default; works on every interpreter
└─▶ lib/libtclble.dylib (in-process extension) ─▶ CoreBluetooth
opt-in; lowest overhead, and the only option on iOS
- Subprocess helper —
bin/ble_helper.bin(universal arm64 + x86_64, built fromble_helper.swift). Speaks a tab-separated line protocol over a stdio pipe. This is the default: it works on every interpreter (tclsh, undroidwish, signed or not). - 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 two backends: a loadable extension runs inside the interpreter, so its Bluetooth access takes on the interpreter's TCC identity. That works for a signed app (or iOS), but an unsignable host like undroidwish cannot get Bluetooth that way — and an in-process attempt there can wedge. The helper re-spawns itself with responsibility disclaimed, becoming its own TCC identity, so it works under any host. It is also architecture- and stubs-independent.
Backend selection. ble.tcl uses the subprocess helper by default; it works on
every interpreter. The native in-process extension is opt-in: set BLE_USE_NATIVE=1
from a host that can hold Bluetooth in-process (a signed app whose Info.plist carries
NSBluetoothAlwaysUsageDescription). Loading the native dylib in a host without a
usage description (plain tclsh, the unsignable undroidwish) makes macOS TCC abort the
whole process with an uncatchable SIGABRT the instant it touches CoreBluetooth, so the
library does not load it unless asked. BLE_NO_NATIVE=1 forces the helper (now the
default, kept for backward compatibility). Both backends expose the identical ble
API.
-
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. Unsigned interpreters (like undroidwish) cannot satisfy that, so the prompt never completes. The helper re-spawns itself with responsibility inheritance disclaimed (
responsibility_spawnattrs_setdisclaim), making it its own responsible process. macOS then evaluates the helper's own signature + embeddedNSBluetoothAlwaysUsageDescriptionand shows a normal prompt; no changes to the host interpreter or app are needed. The grant is approved once and persists.To run inside a signable, grantable host bundle, set
BLE_HELPER_NO_REEXEC=1so the Bluetooth request attributes to that host app instead of to the loose helper binary.
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 bin/ble_helper.bin is included. Rebuilding locally binds the Bluetooth
grant to a locally controlled signature.
package require ble only finds the package if its directory is on Tcl's
auto_path. Use whichever of the following applies — always move the whole
directory, because the package locates bin/ble_helper.bin relative to
ble.tcl.
Option A — one line in the script (no install). Works everywhere; suitable for a self-contained app:
lappend auto_path /path/to/tcl-ble-osx
package require bleOption B — an environment variable (no copying). Tcl prepends TCLLIBPATH
to auto_path at startup, so package require ble then works in both tclsh
and undroidwish with no code change. Add to ~/.zshrc / ~/.bashrc:
export TCLLIBPATH=/path/to/tcl-ble-osx(TCLLIBPATH is a space-separated list for several package dirs.)
Option C — install it once for everything. /usr/local/lib
is on the auto_path of both the system tclsh/wish and undroidwish, so a
single symlink there makes package require ble work everywhere, no config:
ln -s /path/to/tcl-ble-osx /usr/local/lib/tcl-ble-osx
# (use `cp -R` instead of `ln -s` for a copy)Option D — tclsh/wish only. macOS Tcl also scans ~/Library/Tcl:
mkdir -p ~/Library/Tcl
ln -s /path/to/tcl-ble-osx ~/Library/Tcl/tcl-ble-osxVerify any of the above:
echo 'puts [package require ble]; exit' | tclsh # prints 1.0It works in headless tclsh as in wish, subject to the event-loop note above.
A complete scanner script:
package require ble
proc cb {event data} {
if {$event eq "scan"} {
puts "[dict get $data rssi] dBm [string trim [dict get $data name]] [dict get $data address]"
}
}
ble scanner cb
after 15000 {exit} ;# scan 15 s then quit
vwait forever ;# <-- run the event loop so cb actually firesThe first run shows the one-time macOS Bluetooth prompt (attributed to the helper); approve it and the grant persists.
Apps written for AndroWish (like the Decent Espresso de1app) often detect a
working Bluetooth stack with catch { package require ble }, then gate features
on an $::android flag because, historically, only Android had real BLE. Three
things to account for when adding this package on macOS:
-
Make
package require blefind it. Put this directory onauto_path(or add apackage ifneeded ble 1.0 [list source .../ble.tcl]line) before the app's BLE-detection runs. -
Prevent an Android stub from clobbering the real
ble. AndroWish apps commonly define a no-opproc ble {args} { return 1 }as part of stubbing Android-only APIs on desktop; if that runs after this package loads, it replaces the real command, so everyble scanner/ble startbecomes a no-op that returns1and never scans. Guard any such stub:if {[llength [info commands ble]] == 0} { proc ble {args} { ... } ;# only stub when there is no real one }
-
Broaden Android-only feature gates. Replace
$::android == 1BLE gates with a "real BLE is present" test so they also fire on macOS (and iOS/iWish). Compute it once, right afterpackage require bleand before anyblestub could be defined:set ::has_bluetooth [expr {[llength [info commands ble]] > 0}] # then: `$::android == 1` -> `$::has_bluetooth` # `$::android != 1` -> `!$::has_bluetooth`
On macOS, run the app as "undroid" (not "android") so its Android-only APIs (
borg, etc.) still get stubbed, while keeping this realblecommand.
| File | What it does |
|---|---|
examples/scan.tcl |
List nearby BLE devices for 15 s |
examples/skale.tcl |
Connect to an Atomax Skale, stream weight, tare |
examples/de1.tcl |
Monitor a Decent Espresso DE1's machine state (read-only) |
Run any of them with standard Aqua wish:
/usr/local/bin/wish8.6 examples/scan.tclScans for an Atomax Skale, connects, enables weight notifications, shows the weight on the scale's LCD, and tares it after a few seconds (demonstrating a write). Weight notifications are a flag byte followed by a signed little-endian int16 in tenths of a gram:
package require ble
set SUUID 0000FF08-0000-1000-8000-00805F9B34FB ;# Skale service
set CMD 0000EF80-0000-1000-8000-00805F9B34FB ;# write: tare / LCD / timer
set WEIGHT 0000EF81-0000-1000-8000-00805F9B34FB ;# notify: weight
proc cb {event data} {
switch -- $event {
scan {
if {!$::found && [string match -nocase "Skale*" [dict get $data name]]} {
set ::found 1
ble stop $::scanner
set ::skale [ble connect [dict get $data address] cb 0]
}
}
characteristic {
if {[dict get $data state] eq "discovery"} {
set ::sinstance([dict get $data suuid]) [dict get $data sinstance]
set ::cinstance([dict get $data cuuid]) [dict get $data cinstance]
} elseif {[dict get $data cuuid] eq $::WEIGHT} {
binary scan [dict get $data value] xs raw ;# skip flag, signed LE int16
puts [format "%.1f g" [expr {$raw / 10.0}]]
}
}
connection {
if {[dict get $data state] eq "connected"} {
ble enable $::skale $::SUUID $::sinstance($::SUUID) \
$::WEIGHT $::cinstance($::WEIGHT)
}
}
}
}
set ::found 0
set ::scanner [ble scanner cb]The full version (LCD display + tare write + a little UI) is in
examples/skale.tcl.
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 token)
ble info ?<handle>? -> open handles / info for one
ble enable <h> <suuid> <si> <cuuid> <ci> -> 1; enable notifications
ble disable <h> <suuid> <si> <cuuid> <ci> -> 1
ble write <h> <suuid> <si> <cuuid> <ci> ?<writetype>? <data> -> 1
ble read <h> <suuid> <si> <cuuid> <ci> -> 1
ble mtu <h> ?<value>? -> negotiated MTU
ble userdata <h> ?<value>? -> per-handle scratch store
ble state -> central manager state
The callback is invoked as {*}$callback $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 |
value is a binary byte array; name is a string; write accepts the optional
Android write-type (1 = no response, 2 = default).
The sinstance/cinstance integers are assigned during discovery and echoed
back in the discovery events; store them (keyed by UUID, as the examples do) and
pass them to enable/write/read.
/usr/local/bin/wish8.6 test/test_ble.tcl # scan + connect + enable + readtest/build_testapp.sh wraps an interpreter + this library + the test into a
launchable .app, for cases where the interpreter itself cannot be
code-signed (e.g. undroidwish) and the Bluetooth prompt must appear.
- First run shows a one-time macOS Bluetooth prompt. Approve it; the grant persists (keyed to the helper's code signature, so rebuilding re-prompts).
- For distribution, sign
bin/ble_helper.binwith a Developer ID and notarize the containing app so the grant is stable and Gatekeeper-friendly. The embeddedInfo.plistmust keepNSBluetoothAlwaysUsageDescription. BLE_HELPER_NO_REEXEC=1disables the self-disclaim (debugging, or when the host bundle is itself grantable); then TCC falls back to the launching app's identity.
Tcl/Tk license (a BSD-style license). See LICENSE.
Originally written to run the Decent Espresso de1app on macOS; usable by any Tcl
program that needs Bluetooth LE on the Mac.