Blog
Feb 18, 2026 - 9 MIN READ
Smart Home on a Budget: BLE Window Sensors with Two Pico Ws and MicroPython

Smart Home on a Budget: BLE Window Sensors with Two Pico Ws and MicroPython

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

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.


The System

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    │
└─────────────────┘                    └─────────────────┘
  • Window Pico — mounted on the window frame, monitors a magnetic reed switch, advertises over BLE
  • Hub Pico — anywhere in the room, scans for the window sensor, shows status with colored LEDs

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.


Bill of Materials

PartPriceQuantity
Raspberry Pi Pico W~$62
Reed switch (magnetic)~$0.501
LEDs + resistors~$16
Total~$14

No soldering required for prototyping — just a breadboard and jumper wires.


The Peripheral: Window Sensor

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

Key Design Decisions

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:

  1. Power — fewer BLE writes means longer battery life
  2. Bandwidth — BLE notifications are cheap but not free

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.

BLE Advertising

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 Central: Hub

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.

Auto-Reconnect Loop

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.


Concurrent Tasks with asyncio

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.

The Boot Sequence

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.


Deployment with mpremote

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

What I Learned

1. aioble Is Excellent

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().

2. BLE > WiFi for Sensor Networks

My first instinct was WiFi — HTTP requests to a local server. But BLE has three advantages for this use case:

  • No router dependency — works if your WiFi goes down
  • Lower power — BLE uses milliwatts vs WiFi's hundreds of milliwatts
  • Simpler protocol — write bytes to a characteristic vs HTTP request/response

WiFi makes sense when you need cloud connectivity or a web dashboard. For room-level sensor communication, BLE wins.

3. ASCII Messages Are Fine

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.

4. The Pico W's BLE Stack Is Solid

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.


What's Next

This is a prototype. The production version would add:

  • Battery power — the Pico W can run on 2× AA batteries with deep sleep between readings
  • Multiple sensors — one hub scanning for N window peripherals (the central code already handles re-scanning)
  • MQTT bridge — add WiFi to the hub only, forward BLE state to Home Assistant
  • Enclosure — 3D-printed case for the window unit, with the reed switch on a flex cable

The total cost for a 5-window system: ~$40. A commercial equivalent (Aqara, Eve, etc.): $150-250 plus a hub plus a subscription.


GPIO Reference

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.