tether
Write MicroPython and Python in one file, and call across the PC/MCU boundary like it's a normal function call.
Building a project that pairs a microcontroller with a PC-side app usually
means two separate codebases, a hand-rolled wire protocol, and manual
marshalling every time you add a feature. tether collapses that into one
file: decorate a function @mcu.export and it runs on the board; decorate
one @pc.export and it runs on your PC. Call either one from the other
side exactly like a local function call — tether figures out which parts
of the file belong on the MCU, uploads just that code, and handles framing,
serialization, and routing calls over the wire.
Targets ESP32 and similar MicroPython-capable boards. Serial, wifi, and BLE
(via the tether CLI's provisioning step) all work against real hardware
today, including mcu.connect("wifi:<ip>")/mcu.connect("ble:<addr>")
themselves — a full PC-to-MCU call, an MCU-to-PC reverse call, and
remote-exception propagation were all verified end-to-end against a real
ESP32 over each transport. See Transports below.
How it works
- One file, two runtimes. The same
.pyfile runs as a normal script on your PC and (in sliced form) as the program running on the MCU.@mcu.export/@mcu.loopmark MCU-bound functions;@pc.exportmarks PC-bound ones. - AST slicing, not manual splitting. At connect time,
tetherwalks the file's syntax tree from every@mcu.export/@mcu.loopfunction, pulling in whatever helper functions, module-level assignments, and local imports it actually depends on. Only that subset gets uploaded — you don't maintain a separate MCU-only file by hand. - Calls cross the boundary transparently. Every exported function gets a matching stub generated on the other side. Calling an MCU function from the PC (or a PC function from the MCU) sends a request over the wire, waits for the result, and returns it like any other function call — including calls made from inside a call already in flight, in either direction.
- Type-checked at the boundary. Only
int,float,bool,str,bytes,list, anddict(recursively) can cross — enforced from type hints at decoration time, so an unsupported type is a definition-time error, not a surprise mid-call.
Install
uv pip install -e ".[serial]" # or "[ble]" for Bluetooth
# connecting over wifi (mcu.connect("wifi:<ip>")) needs no extra - pure
# stdlib socket. Provisioning a board for wifi needs "[cli]" - see below.
Example
from tether import mcu, pc
@pc.export
def log_event(msg: str) -> None:
print("MCU says:", msg)
@mcu.export
def read_temp() -> float:
# Hardware imports go INSIDE the function body: this file also runs
# directly on your PC, where `machine` doesn't exist.
from machine import ADC, Pin
return ADC(Pin(4)).read_u16() / 65535
mcu.connect("serial:auto")
print(read_temp())
connect() sets itself as the ambient "current board" — call an
@mcu.export function like any other Python function, no board-awareness
needed at the call site. board = mcu.connect(...) still works if you want
to be explicit (board.read_temp()), or need to juggle more than one board
at once (with board: scopes which one is ambient for a block).
Transports
| Transport | Address | Notes |
|---|---|---|
| Serial | "serial:auto" (USB auto-discovery) or an explicit port |
Pushes code over MicroPython's raw REPL — the only transport that works on a completely unprovisioned board (wifi/BLE need tether provision wifi/tether provision ble first; see below). |
| Wifi | "wifi:<ip>" |
Once a board is provisioned (tether provision wifi, see below), mcu.connect("wifi:<ip>") slices, hash-checks, and uploads code automatically before running — same as serial, no prior serial session required. Authenticated by default — every connection needs a shared secret (tether provision wifi generates and prints one; pass it via mcu.connect(secret=...) or the TETHER_WIFI_SECRET env var) unless the board was provisioned with --danger-unauthenticated. tether status --ip <ip> is fast and non-destructive (no reset). board.reconnect() works over wifi too — sequential only, one connection at a time. Credentials and the shared secret are both stored in plaintext on-device (/tether_wifi.json) — the only realistic option on this hardware class, no secure storage exists. See "WiFi & BLE provisioning CLI" below for the full picture. |
| BLE | "ble:<addr>" |
Once a board is provisioned (tether provision ble, see below), same code-push/auth/status model as wifi (shared secret, --danger-unauthenticated, tether status --ble-addr <addr>), but reuses one BLE connection across status/upload/run instead of opening a fresh one per mode — connection setup is comparatively expensive over BLE. Wifi and BLE are mutually exclusive on one board (provisioning one warns before overwriting the other's boot.py — a board only auto-runs one at a time). macOS note: CoreBluetooth hides real BLE MAC addresses from apps for privacy; mcu.connect("ble:<addr>") on macOS needs the randomized UUID a BLE scan reports, not the MAC tether provision ble prints (correct and usable as-is on Linux/BlueZ). |
Walkthrough: blink an LED
A complete, runnable example lives in examples/blink_and_log/. It blinks
an onboard LED a set number of times from a PC script, with the MCU logging
progress back after each blink — exercising both @mcu.export (PC calls
MCU) and @pc.export (MCU calls PC) in one file.
uv pip install -e ".[serial]"
cd examples/blink_and_log
python blink_and_log.py
Expected output:
Connected. Blinking 5 times...
blink 1/5
blink 2/5
blink 3/5
blink 4/5
blink 5/5
Done.
The example hardcodes Pin(2, Pin.OUT) — pin 2 is the common onboard LED
pin on many ESP32 dev boards; check yours and adjust if it doesn't light
up.
examples/wifi_blink/ and examples/ble_blink/ are the wifi/BLE
equivalents — same @mcu.export/@pc.export code, but connecting over
"wifi:<ip>"/"ble:<addr>" instead of serial (after tether provision wifi/tether provision ble — see below), and additionally demonstrating
board.reconnect() with no physical reset needed:
uv pip install -e ".[cli,serial,ble]"
tether provision wifi --ssid YOUR_SSID # or: tether provision ble
tether status # prints the board's IP
export TETHER_WIFI_SECRET=<secret from provision wifi>
python examples/wifi_blink/wifi_blink.py <board-ip>
Both verified end-to-end against real ESP32 hardware, including the reconnect.
WiFi & BLE provisioning CLI
Install the tether[cli] extra to get the tether console script:
uv pip install -e ".[cli]"
tether devices # list connected boards
tether provision wifi --ssid SSID [--password PW] # upload boot.py + credentials + a secret
tether provision ble [--danger-unauthenticated] # upload boot.py + a secret, advertises the board
tether status [--ip IP] [--secret S] [--ble-addr A] [--ble-secret S] # check provisioned/connected state
tether unprovision # remove all stored credentials (wifi + BLE)
--port is optional everywhere — if more than one known device is
connected and --port is omitted, you're prompted interactively to pick
one. --password is prompted for (hidden input) if omitted from
provision wifi. unprovision removes whichever of /tether_wifi.json/
/tether_ble.json are actually present — a board only ever runs one
transport at a time (see the boot.py-conflict warning below), so there's
no reason to unprovision one transport but not the other. It asks for
confirmation first, since it kills the board's wifi/BLE reachability (it
only removes the stored credentials — the uploaded boot.py itself stays,
harmlessly, and does nothing without them). provision wifi uploads a
small boot.py that
auto-connects to wifi on every boot and, once connected, loops
indefinitely accepting connections (one at a time) that push code, run
it, or report status — after it finishes, connect from Python with
mcu.connect("wifi:<ip>"), using the IP tether status reports.
provision wifi prints a secret — save it, or you can't connect. By
default every provision wifi run generates a fresh random shared secret
and prints it once, right after the IP-related output:
Provisioned /dev/cu.usbserial-0001 for wifi network 'MyNetwork'. Board is restarting.
Shared secret (save this - needed to connect): 3f9a1c...
Run `tether status` in a few seconds to check connectivity.
There is no way to recover this secret later short of re-provisioning
(which rotates it again). Pass it to mcu.connect("wifi:<ip>", secret="..."), or set it once as an environment variable so you don't
have to pass it every time:
export TETHER_WIFI_SECRET=3f9a1c...
A wrong or missing secret raises tether.WifiAuthError, not a generic
timeout — if you see that, double-check the secret you saved. If you
deliberately don't want authentication (e.g. a trusted, isolated bench
network), pass --danger-unauthenticated to provision wifi: no secret
is generated, and the board's listener accepts any connection from
anyone on the network. This prints a loud warning but does not prompt for
confirmation, so it stays scriptable.
tether status --ip <ip> is now the fast, non-destructive path.
Given --ip (and --secret/TETHER_WIFI_SECRET if the board is
authenticated), status talks directly to the board's wifi listener —
no reset, no interruption of anything it's doing. It only falls back to
the older, serial-based raw-REPL diagnostic (which does reset the board)
if the wifi connection attempt itself fails — meaning wifi never came up
in the first place (bad password, out of range), not that the board is
merely busy. Without --ip, status always uses the serial fallback
directly, same as before.
Code push works automatically now. mcu.connect("wifi:<ip>") slices
your script, checks the on-device bundle hash (via a fast status-mode
query), and uploads the full bundle first if it's missing or stale —
the same slice → hash-check → upload-if-needed → run flow serial has
always done. A prior serial mcu.connect(...) session is no longer a
prerequisite for connecting over wifi.
Reconnecting works too. board.reconnect() now succeeds over wifi
(the board's accept-loop re-listens after each connection ends) — it's
still sequential-only, one connection at a time, so you can't check
status while a run session is actively live, only in between.
BLE
tether provision ble [--danger-unauthenticated] is the BLE equivalent of
provision wifi — no network credentials needed (BLE just advertises),
but everything else matches: a fresh shared secret generated and printed
by default (mcu.connect(secret=...) or TETHER_BLE_SECRET env var;
tether.WifiAuthError on a bad/missing one, reused as-is for BLE — same
exception class, both transports), tether status --ble-addr <addr> [--ble-secret ...] as the fast non-destructive path, and full code push
(mcu.connect("ble:<addr>") slices/hash-checks/uploads exactly like wifi).
One real difference from wifi: BLE connection setup is comparatively
expensive, so status/upload/run all reuse a single BLE
connection instead of wifi's one-connection-per-mode — only run
finishing, an auth failure, or an unrecognized mode ends the session.
Wifi and BLE are mutually exclusive on one board — MicroPython only
auto-runs one /boot.py, so provisioning one warns (doesn't block) before
overwriting the other's; the other's credentials file is left in place but
nothing reads it anymore.
macOS-specific: CoreBluetooth hides a device's real BLE MAC address
from apps for privacy, exposing a randomized per-app UUID instead.
provision ble prints the board's real MAC (correct and directly usable
on Linux/BlueZ) — on macOS, get the UUID to connect with from a BLE scan
(e.g. bleak.BleakScanner.discover()) instead.
Status
Serial, wifi, and BLE are all implemented and verified against real ESP32
hardware, including code push, shared-secret auth (wifi/BLE), reconnecting,
and re-running repeatedly. A PC-to-MCU call, an MCU-to-PC reverse call, and
remote-exception propagation are confirmed working over each transport. See
the Transports table above and "WiFi & BLE provisioning CLI" for the full
picture, and docs/DESIGN.md for each transport's remaining, accepted
limitations (sequential connections only, plaintext credentials/secret
on-device, no TLS, no BLE pairing/bonding, no concurrent wifi+BLE on one
board). Every connection is authenticated by an HMAC-SHA256 nonce
challenge, not a plaintext secret sent as-is — see docs/DESIGN.md's
Transports table for how it works.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tether_mcu-0.1.1.tar.gz.
File metadata
- Download URL: tether_mcu-0.1.1.tar.gz
- Upload date:
- Size: 333.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
72fb3213633c0e6c20369edf7d513a1e1973c95454683c6c4ee4fa8ce389b1b6
|
|
| MD5 |
74311af5f218b60bee93e441f56d228c
|
|
| BLAKE2b-256 |
cfe109624a9643fb5a56e32bac56c8b17931b1bf6750086e522c54a704b3eedc
|
File details
Details for the file tether_mcu-0.1.1-py3-none-any.whl.
File metadata
- Download URL: tether_mcu-0.1.1-py3-none-any.whl
- Upload date:
- Size: 96.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e91d3084d62af58135403f39ed14a6e9078da397f6fa8f3b2bf3bd1fb511f5d3
|
|
| MD5 |
1112a04331871709b4385289df648d54
|
|
| BLAKE2b-256 |
622180c4b1df50dbef636a22567440bea90087fa2d206cdfb344ef8395f2ae4d
|