switchbot-ble-webhook
A local HTTP webhook that presses a physical SwitchBot button over Bluetooth.
- Python
- Flask
- Bleak
- BLE
Exposes a simple HTTP webhook that presses a SwitchBot Bot over Bluetooth Low Energy directly — no SwitchBot Hub, no cloud account, no app.
The problem
A SwitchBot Bot is a small robot arm that physically presses a button — a way to automate a switch you can’t rewire. SwitchBot’s own path to trigger one remotely runs through their Hub hardware, their cloud, and their app.
This project skips all three. It talks straight to the Bot’s own Bluetooth Low Energy radio
from any machine with a Bluetooth adapter in range, and exposes that as one HTTP endpoint —
so anything that can make a POST request (a cron job, a home-automation tool, a shell
script) can trigger a physical press, with nothing but the local network in between.
Approach and methodology
The whole trick is one BLE GATT write. A SwitchBot Bot’s press command is a fixed, three-byte
payload (0x57 0x01 0x00) sent to a specific characteristic
(cba20002-224d-11e6-9fb8-0002a5d5c51b) — a publicly documented device-level command, not
something that comes from SwitchBot’s own developer API, which only talks to their cloud.
press.py opens a direct BLE connection with bleak (a cross-platform Bluetooth library for
Python — Linux, macOS, Windows), writes those three bytes, and disconnects.
Finding the Bot in the first place is the second small piece. scan.py filters Bluetooth
advertisements down to actual SwitchBot Bots by checking the advertisement’s manufacturer ID
(0x0969) rather than listing every BLE device nearby — the README documents a generic
bluetoothctl scan as a manual fallback for the same job.
The design is deliberately dumb: every press request shells out to press.py as its own
subprocess rather than holding a persistent BLE connection or a queue — connect, write,
disconnect, once per press. Simple, and not free (see Limitations).
Built solo in a single day at first — the original three commits are all dated 2025-12-15,
and of the four projects here it was the only one without an agentic development process
recorded in the repo (no AGENTS.md). A mocked-Bluetooth unit test suite (16 tests, make test, no adapter or real Bot needed) was added later. No CI, by choice rather than oversight
— a single-file personal webhook doesn’t need a pipeline to stay honest; the same make test
that would run in one runs identically on a laptop.
Architecture
Three endpoints, no authentication layer of any kind — the API trusts whoever can reach port 5000:
| Endpoint | Method | Does |
|---|---|---|
/devices |
GET | Scans for nearby SwitchBot Bots, returns MAC address, name, advertised UUIDs |
/devices/{mac}/press |
POST | Presses the Bot at that MAC address |
/health |
GET | Liveness check |
api.py is a plain Flask app bound to 0.0.0.0:5000. The press path resolves the venv’s own
Python interpreter and runs press.py as a subprocess with a 10-second timeout, capturing
stdout/stderr into the JSON response rather than letting a hung BLE connection hang the
request forever.
Results
What’s real: a working three-endpoint Flask API, MIT-licensed and public on GitHub, that
presses a SwitchBot Bot over BLE with no SwitchBot account anywhere in the path — verified by
hand through the README’s own curl walkthrough, including a LAN test from a second machine,
and covered by a 16-test unit suite that mocks Bluetooth entirely: the manufacturer-ID device
filter, the GATT write’s characteristic and command bytes, and the API’s success/failure/
timeout paths.
What isn’t: no CI, and no measured range or reliability numbers — a deliberate scope cut for something built to solve one specific, small problem, not a gap that was simply missed.
Limitations
- No authentication on the API at all. The local network is the only thing standing between “anyone who can reach port 5000” and “can press your button” — no token, no password, nothing the app itself checks.
- A fresh BLE connection per press, opened and torn down by a new subprocess every time — simple, but not the low-latency path a persistent connection or a small connection pool would give.
- Press only. The GATT write implements the Bot’s momentary-press command; other SwitchBot
Bot behaviors aren’t wired up, and no other SwitchBot product line (Curtain, Meter, Hub) is
supported —
scan.py’s manufacturer-ID filter is Bot-specific.
Work in progress
None planned. This one is done: it solves the one problem it was built for, and there’s no further work on the roadmap.