User Guide¶
Overview¶
chumicro-wifi is a single wifi service that works the same way on CircuitPython, MicroPython-on-ESP32, MicroPython-on-Pi-Pico-W, and CPython. Construct it once, hand it to the runner, and it brings the link up, watches for drops, and reconnects on its own: no CIRCUITPY_WIFI_* keys, no firmware-level auto-reconnect, no boot.py that connects before user code runs. Owning the radio in one place eliminates a class of "two systems both think they own the radio" bugs.
Public surface: WifiConfig (typed settings), WifiService (the supervisor), WifiState (the five-value state machine), plus chumicro_wifi.testing.FakeWifi for downstream library tests.
Getting started¶
Typical wiring, with config loaded from the deployed runtime-config file, the service registered with the runner, ready in a few lines of app code:
from chumicro_config import load_runtime_config
from chumicro_runner import Runner
from chumicro_wifi import WifiService
config = load_runtime_config()
wifi = WifiService.from_config(config)
runner = Runner()
runner.add(wifi) # check/handle integration
while True:
runner.tick()
if wifi.connected:
# do whatever needs the network
pass
WifiService.from_config(config) reads the flat wifi.* keys (the table in Configuration); any extra keyword (adapter=, ticks=) passes through to the constructor, and radio= hands the CircuitPython adapter a specific radio object. WifiService calls into the right per-runtime adapter automatically. No platform branches in your app.
State machine¶
WifiState has five string-sentinel values:
DISCONNECTED -> CONNECTING -> CONNECTED
| | ^
| v | (link restored)
| RECONNECTING (link dropped)
| |
v v
FAILED <--- backoff exhausted (if reconnect_max set)
wifi.state returns the current sentinel; compare with ==:
(Plain string comparison: enum.Enum is unavailable on some MicroPython boards, so the sentinels are bare strings.)
wifi.connected is shorthand for state == CONNECTED.
Reading IP and errors¶
After the supervisor reaches CONNECTED:
wifi.ip # "192.168.1.42" or None
wifi.last_error # last exception caught, if any
wifi.adapter.name # "cp" / "mp_esp32" / "mp_rp2" / "fake", useful for logging
last_error is most informative on MicroPython-ESP32, where the wifi driver raises OSError("Wifi Internal State Error") on unreachable AP: the service captures it and surfaces it here. On CircuitPython, the substrate raises TimeoutError / ConnectionError (both OSError subclasses) but CpWifiAdapter.connect catches them and returns False, so last_error typically stays None for unreachable-AP cases. Only non-OSError failures (e.g. programmer errors) bubble up. On MicroPython-CYW43 (Pi Pico W) the driver silently leaves isconnected() False with no exception, so last_error is None even though the supervisor is in RECONNECTING (see Platform notes).
State-change notifications¶
on_state_change(callback) appends callback to the transition-listener list, and every registered callback fires on every transition, in registration order:
def log_transition(old_state, new_state):
print(f"wifi: {old_state} -> {new_state}")
wifi.on_state_change(log_transition)
Fan-out needs no bus: every registered callback fires on each transition, so each interested component registers its own on_state_change handler directly.
To let a generator task block on a one-time transition (the first time the link comes up, say), bridge the callback to a Signal and yield from wait_for(...) (both in chumicro_timing.waits; see the chumicro-timing guide) rather than polling wifi.state:
from chumicro_timing.waits import Signal, wait_for
link_up = Signal()
wifi.on_state_change(lambda old, new: link_up.set(new))
def main_run():
yield from wait_for(link_up) # suspend until the next wifi transition
...
Configuration¶
WifiConfig.from_config(config) reads the flat wifi.* keys from a RuntimeConfig (or plain dict with the same shape); WifiService.from_config(config) wraps that loader and constructs the service in one call. try_from_config(config) is the soft variant that returns None when the section isn't deployed. Accepted keys:
| Key | Required | Default | Notes |
|---|---|---|---|
ssid |
✅ | none | AP SSID. |
password |
✅ | none | WPA passphrase. |
hostname |
None |
Hostname advertised on the AP. | |
connect_timeout_ms |
15_000 |
Per-attempt connect deadline: a blocking wait on CircuitPython, the in-flight association poll window on MicroPython. | |
first_connect_timeout_ms |
None (use connect_timeout_ms) |
Allowance for the first connect attempt after construction. A cold radio's first association after power-up runs longer than a steady-state reconnect; set this above connect_timeout_ms so the first attempt can ride it out. Every later attempt, reconnects included, uses connect_timeout_ms. |
|
reconnect_backoff_start_ms |
1_000 |
Initial reconnect delay. | |
reconnect_backoff_max_ms |
60_000 |
Exponential-backoff cap. | |
reconnect_max |
None (unlimited) |
Consecutive failed attempts (initial connect + reconnects) before the terminal FAILED state. Leave None for always-on devices (see below). |
|
power_save |
False |
Leave radio power-save on. False disables it on both MicroPython stacks (CYW43 on Pi Pico W, ESP-IDF on esp32); ignored on adapters without the knob (CircuitPython). |
|
tx_power_dbm |
None (radio default) |
Radio transmit power in dBm. None leaves the firmware default untouched; set a reduced value (e.g. 15) on boards unstable at full power. Applied via wifi.radio.tx_power (CP) / sta.config(txpower=…) (MP); ignored on ports without the knob. |
# Inside your project's runtime config (TOML on disk; deploy-flattened to msgpack on the device).
[wifi]
ssid = "HomeNet"
password = "secret"
hostname = "back-porch"
power_save = false # default; eliminates ~30-100 ms tick spikes
The power_save = false default matters on Pi Pico W: the CYW43 chip's idle power-save mode introduces 30–100 ms tick stalls, which visibly stutter LED-blink rhythms and can break sub-second control loops.
tx_power_dbm exists for boards that are unreliable at full transmit power. The motivating case is Unexpected Maker's P4-revision ESP32-S3 boards, which are vendor-documented unstable at full 20 dBm. Dropping to tx_power_dbm = 15 (~75 %) restores a clean join. This knowledge lives in your deploy config, not in the library: chumicro-wifi never inspects the board, it only applies the value you set and leaves the radio at its firmware default when the key is absent.
first_connect_timeout_ms exists for the cold start. The first association after power-up runs longer than the reconnects that follow, and without a grace it can exhaust connect_timeout_ms, count a failure toward reconnect_max, and burn a backoff delay before the now-warm radio joins on the retry. Set it above connect_timeout_ms (say 45_000 against the default 15_000) and only the first dispatched attempt uses it; the supervisor hands every later attempt connect_timeout_ms, so steady-state reconnect behavior is unchanged.
reconnect_max and the never-restart guarantee¶
Leaving reconnect_max at its None default is what lets an unattended device ride out an outage without a reboot: the supervisor retries forever with backoff capped at reconnect_backoff_max_ms, so a link that comes back after minutes, hours, or a whole-house power blip is re-established on its own. FAILED is a terminal state (nothing in the service leaves it), so set a finite reconnect_max only when a caller wants exhaustion to escalate (e.g. to a hardware watchdog reset or deep-sleep), and remember the count includes the initial connect: a low cap can fail permanently in the power-restore race where the board boots faster than the router. For always-on devices, keep it None.
Runner integration¶
WifiService implements the chumicro-runner check(now_ms) / handle(now_ms) contract:
check is cheap (state inspection); handle performs at most one wifi-driver call per tick. On MicroPython that call is non-blocking: association happens in the background and handle() returns immediately, so other services keep their tick budget. On CircuitPython the substrate-level wifi.radio.connect() is itself blocking, so handle() stalls for up to connect_timeout_ms (default 15 000 ms; the first attempt uses first_connect_timeout_ms when set) while in CONNECTING / RECONNECTING. Other services in the same Runner (LED heartbeat, an in-flight HTTP request, MQTT keep-alives) pause for that window. Once CONNECTED, every tick is cheap on both runtimes, and connection failures land in RECONNECTING, with the next backoff window resuming naturally.
Adapter detection¶
WifiService picks the right adapter at construction time based on sys.implementation.name:
| Runtime | Adapter | File |
|---|---|---|
| CircuitPython | CpWifiAdapter (uses wifi.radio) |
_adapters/cp.py |
| MicroPython | MpWifiAdapter (handles ESP32 + CYW43) |
_adapters/mp.py |
| CPython | CpythonWifiAdapter (host stand-in, reports success immediately) |
_adapters/cpython.py |
The MpWifiAdapter auto-detects ESP-IDF vs CYW43 by matching sys.implementation._machine against a positive whitelist of known CYW43 boards (CYW43_MACHINES in _adapters/mp.py); anything outside the whitelist falls through to ESP-IDF. It then applies the right wlan.config(...) knobs:
- ESP-IDF:
config(reconnects=0)after first link, to disable the firmware-level auto-reconnect supervisor:chumicro-wifiowns reconnect logic itself. - CYW43:
config(pm=0xa11140)at configure time, to disable idle power-save whenpower_save=False.
The underlying MicroPython network.WLAN API (active, connect, isconnected, ifconfig) is identical across both wifi chips, so a single adapter handles both.
Platform notes¶
Three runtimes, three different ways an unreachable AP surfaces:
| Runtime + chip | When AP is unreachable |
|---|---|
CircuitPython wifi.radio |
Blocks inside connect() until timeout= expires, raises TimeoutError / ConnectionError (both are OSError subclasses). |
MicroPython on ESP32 (network.WLAN) |
Returns immediately from connect(), then raises OSError("Wifi Internal State Error") on the next interaction. |
| MicroPython on CYW43 (Pi Pico W) | Returns immediately, isconnected() silently stays False, no exception. |
The supervisor handles all three honestly: each adapter checks isconnected() after a connect attempt rather than trusting that a non-raising connect() succeeded.
On the ESP32-S3, a failed or timed-out wifi.radio.connect() leaves the station half-open; re-issuing connect() without clearing it makes the retry slow-fail for the whole connect_timeout_ms (surfacing as ConnectionError: Unknown failure 205) instead of the ~4 s a clean attempt takes, so a single transient RF glitch cascades past the connect budget. CpWifiAdapter.connect therefore calls wifi.radio.stop_station() before each fresh attempt (and short-circuits when the radio already reports linked), keeping every attempt independent. This is an intermittent, RF-marginal failure mode. The chip connects in seconds when the station is clean.
Testing with FakeWifi¶
For downstream libraries' tests, chumicro_wifi.testing.FakeWifi is a drop-in WifiService wrapping a FakeWifiAdapter with set_connect_outcome, drop_link, and calls hooks.
Examples¶
| Example | What it shows |
|---|---|
examples/connect_to_ap.py |
Connect to a real AP, print state transitions, observe IP, reading wifi.ssid / wifi.password from runtime_config.msgpack. |