How I built a wireless window monitoring system using two Raspberry Pi Pico Ws, reed switches, and BLE — with async MicroPython, aioble, and zero cloud dependencies.
Mariusz Smenzyk
AI Developer ✨ MusicTech ✨ SportTech
Commercial smart home sensors cost $30-50 each, require a cloud subscription, and stop working when the company shuts down its servers. I built a window monitoring system for under $15 total — two Raspberry Pi Pico Ws, two reed switches, and six LEDs. No cloud. No app. No subscription. Just BLE and MicroPython.
Two Pico Ws talk over BLE (Bluetooth Low Energy):
┌─────────────────┐ BLE ┌─────────────────┐
│ WINDOW PICO │ ───────────────► │ HUB PICO │
│ (peripheral) │ │ (central) │
│ │ │ │
│ Reed switch ── │──► OPEN/CLOSED ──► │ ── Green LED │
│ Red LED │ │ ── Red LED │
│ Yellow LED │ │ ── Yellow LED │
└─────────────────┘ └─────────────────┘
When the window opens (magnet moves away from reed switch), the window Pico sends "OPEN" over BLE. The hub turns its green LED on. When it closes: "CLOSED", red LED on. That's it.
| Part | Price | Quantity |
|---|---|---|
| Raspberry Pi Pico W | ~$6 | 2 |
| Reed switch (magnetic) | ~$0.50 | 1 |
| LEDs + resistors | ~$1 | 6 |
| Total | ~$14 |
No soldering required for prototyping — just a breadboard and jumper wires.
The window Pico runs as a BLE peripheral. It monitors a reed switch on GPIO 14 and advertises an Environmental Sensing Service:
from machine import Pin
import aioble
import bluetooth
import asyncio
reed_switch = Pin(14, Pin.IN, Pin.PULL_UP)
led_red = Pin(15, Pin.OUT)
BLE_SVC_UUID = bluetooth.UUID(0x181A) # Environmental Sensing
BLE_CHAR_UUID = bluetooth.UUID(0x2A6E) # Custom characteristic
async def monitor_reed_switch(characteristic):
last_state = None
while True:
current_state = reed_switch.value()
if current_state != last_state:
if current_state == 1: # open (no magnet)
led_red.value(1)
message = "OPEN"
else: # closed (magnet near)
led_red.value(0)
message = "CLOSED"
characteristic.write(message.encode("utf-8"))
last_state = current_state
await asyncio.sleep(0.1) # 100ms debounce
State-change detection, not polling. The sensor only sends a BLE message when the state changes, not on every loop iteration. This is important for two reasons:
Internal pull-up resistors. Reed switches are simple: closed circuit when a magnet is near, open circuit otherwise. Using Pin.PULL_UP means no external resistor needed — GPIO reads 0 when closed, 1 when open.
100ms async debounce. Reed switches can bounce for a few milliseconds when the magnet transitions. asyncio.sleep(0.1) is long enough to filter noise, short enough to feel instant.
The peripheral advertises continuously, waiting for a central to connect:
async def run_peripheral_mode():
ble_service = aioble.Service(BLE_SVC_UUID)
characteristic = aioble.Characteristic(
ble_service,
BLE_CHAR_UUID,
read=True,
notify=True,
)
aioble.register_services(ble_service)
while True:
async with await aioble.advertise(
interval_us=500000, # advertise every 500ms
name="Peripheral",
services=[BLE_SVC_UUID]
) as connection:
print("Connected:", connection.device)
await asyncio.create_task(
monitor_reed_switch(characteristic)
)
print("Disconnected. Re-advertising...")
When the connection drops, it immediately re-advertises. No manual recovery needed.
The hub Pico scans for the peripheral, connects, and reads the characteristic to control LEDs:
async def ble_scan():
async with aioble.scan(
5000, # 5-second scan window
interval_us=30000,
window_us=30000,
active=True
) as scanner:
async for result in scanner:
if (result.name() == "Peripheral" and
BLE_SVC_UUID in result.services()):
return result
return None
async def receive_data_task(characteristic):
while True:
data = await characteristic.read()
if data:
message = data.decode("utf-8")
if message == "OPEN":
led_green.value(1)
led_red.value(0)
elif message == "CLOSED":
led_green.value(0)
led_red.value(1)
The scan filters by both device name and service UUID — not just one. This prevents connecting to random BLE devices in range.
The central wraps everything in a retry loop:
async def run_central_mode():
while True:
device = await ble_scan()
if device is None:
continue
try:
connection = await device.device.connect()
except asyncio.TimeoutError:
continue
async with connection:
try:
service = await connection.service(BLE_SVC_UUID)
characteristic = await service.characteristic(BLE_CHAR_UUID)
except (asyncio.TimeoutError, AttributeError):
continue
await asyncio.create_task(
receive_data_task(characteristic)
)
If the connection drops — scan again. If the scan times out — try again. The hub will find the window sensor eventually.
MicroPython on the Pico W supports asyncio — and it's the right way to handle multiple I/O tasks without threads:
async def main():
tasks = [
asyncio.create_task(monitor_vsys()),
asyncio.create_task(run_peripheral_mode()),
]
await asyncio.gather(*tasks)
asyncio.run(main())
This runs reed switch monitoring, VSYS power monitoring, and BLE advertising concurrently — all in a single thread. No race conditions, no locks, no shared memory issues.
A boot.py file runs before main.py and blinks all LEDs as a visual "I'm alive" signal:
from machine import Pin
import utime
leds = [Pin(13, Pin.OUT), Pin(14, Pin.OUT), Pin(15, Pin.OUT)]
for _ in range(5):
for led in leds:
led.value(1)
utime.sleep(0.1)
for led in leds:
led.value(0)
utime.sleep(0.1)
import main
Five fast blinks, then the app starts. Simple, but incredibly useful when you're debugging a headless device — you know immediately if the Pico booted or is stuck.
No IDE needed. mpremote handles everything from the terminal:
# Upload code to the Pico
mpremote connect /dev/cu.usbmodem101 fs cp main.py :main.py
# Open a REPL
mpremote connect /dev/cu.usbmodem101 repl
# Run without uploading (for testing)
mpremote run main.py
# Monitor serial output
screen /dev/cu.usbmodem101 115200
I use a Makefile to avoid typing device paths:
PORT ?= /dev/cu.usbmodem101
upload:
mpremote connect $(PORT) fs cp main.py :main.py
mpremote connect $(PORT) fs cp boot.py :boot.py
repl:
mpremote connect $(PORT) repl
logs:
mpremote connect $(PORT) run :main.py
MicroPython's aioble library is genuinely well-designed. The async with await aioble.advertise(...) pattern handles connection lifecycle cleanly — when the block exits (disconnect), it automatically restarts. Same for aioble.scan().
My first instinct was WiFi — HTTP requests to a local server. But BLE has three advantages for this use case:
WiFi makes sense when you need cloud connectivity or a web dashboard. For room-level sensor communication, BLE wins.
I send "OPEN" and "CLOSED" as UTF-8 strings over BLE. Could I pack this into a single bit? Yes. Does it matter on a system with one message per state change? No. Readability beats optimization when you're debugging at 2 AM with a serial monitor.
I expected crashes, weird disconnects, stack overflows. Instead, the Pico W's BLE stack (via CYW43) just works. Advertising, scanning, connecting, reading, writing — all stable. The aioble library abstracts the complexity well.
This is a prototype. The production version would add:
The total cost for a 5-window system: ~$40. A commercial equivalent (Aqara, Eve, etc.): $150-250 plus a hub plus a subscription.
Window Pico (Peripheral) Hub Pico (Central)
┌──────────────────────┐ ┌──────────────────────┐
│ GPIO 14: Reed switch │ │ GPIO 13: Yellow LED │
│ GPIO 15: Red LED │ │ GPIO 14: Green LED │
│ GPIO 13: Yellow LED │ │ GPIO 15: Red LED │
└──────────────────────┘ └──────────────────────┘
Built with 2× Raspberry Pi Pico W, MicroPython, aioble, and $14 worth of components.