LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

ESP32 and MicroPython for the Homelab: Custom Sensors and Home Assistant Integrations from Scratch

esp32micropythonhome-assistantiotmqttesphomesensorshomelabhardware

The ESP32 sits in an interesting position for software engineers. It is powerful enough to run a real Python interpreter, cheap enough to deploy a dozen of them around a house without agonising over the budget, and well-documented enough that you can go from zero to a working temperature sensor publishing to Home Assistant in an afternoon. But the hardware side is unfamiliar territory for most server-side or web engineers, and the ecosystem is fragmented enough to waste several afternoons picking the wrong chip, wrong toolchain, or wrong integration approach before you find the combination that actually works.

This post is the practical entry point for engineers who write software professionally and want to start building real hardware sensors without learning C++ or wading through Arduino tutorials aimed at complete beginners. It covers chip selection, MicroPython setup, the sensor wiring patterns you will use repeatedly, MQTT integration, Home Assistant connectivity, and the honest comparison between MicroPython and ESPHome — because the right answer for a homelab is almost always “both, for different things.”


The ESP32 Family: Which Chip to Buy

Espressif has shipped enough ESP32 variants that the selection question now takes real effort. The families break down by CPU architecture, connectivity, and intended use case.

Chip CPU Cores Wi-Fi BT Thread/Zigbee Notes
ESP32 (original) Xtensa LX6 2 802.11n BT 4.2 + Classic No Still widely available, most tutorials target this
ESP32-S2 Xtensa LX7 1 802.11n None No USB-OTG, very low power; no Bluetooth is a real limitation
ESP32-S3 Xtensa LX7 2 802.11n BLE 5.0 No Best choice for camera, AI inference, display projects
ESP32-C3 RISC-V 1 802.11n BLE 5.0 No Cheapest modern option, good security features
ESP32-C6 RISC-V 1+1 802.11ax (Wi-Fi 6) BLE 5.3 Yes (802.15.4) Best for new smart home builds; Matter/Thread-ready
ESP32-H2 RISC-V 1 None BLE 5.3 Yes Thread/Zigbee coordinator only, no Wi-Fi
ESP32-P4 RISC-V 2+2 None (external) None No High-performance compute, not a sensor node chip

For a homelab sensor node starting in 2026, buy the ESP32-C6. It is Wi-Fi 6 capable, supports Thread and Zigbee via its 802.15.4 radio, and costs roughly the same as the original ESP32. If your Home Assistant instance runs Matter or you plan to add a Thread border router, the C6 gives you the hardware foundation. The ESP32-C3 is a reasonable budget choice if you are buying in quantity and genuinely don’t need Thread.

The original ESP32 (dual-core Xtensa) is fine if you have them on hand or find them at a significant discount. The S3 is the right choice for anything that needs a camera or more compute — a doorbell, a people-counting sensor, or an edge inference project. Avoid the S2 unless you specifically need USB-OTG and have no use for Bluetooth.

Development boards

The bare chip needs to be on a development board for easy prototyping. The form factors that matter:

  • 38-pin DevKit (most common): breadboard-friendly, USB-C on most modern versions, exposes all GPIOs. The standard for any project without space constraints.
  • Super Mini / C3 Mini / C6 Mini: half the size of a DevKit, designed to fit in small enclosures. Fewer pins exposed but usually enough for a sensor node.
  • WROOM / WROVER modules: the module (chip + flash + antenna) without a full DevKit carrier. Used when you’re building your own PCB.
  • Seeed XIAO ESP32C6: high quality small form factor, USB-C, good antenna, popular for enclosure builds.

For breadboard prototyping, the standard 38-pin DevKit form factor works with every tutorial you’ll find. For final deployed sensors, a mini/small form factor board fits into standard electrical boxes, project enclosures, and 3D-printed cases.


MicroPython: Setup and First Steps

MicroPython is a Python 3 implementation designed for microcontrollers. It is not CPython with a hardware shim — it has its own bytecode compiler, garbage collector, and a hardware abstraction layer that maps Python objects directly to hardware peripherals. The subset of the standard library it supports is substantial: os, sys, json, re, struct, time, math, random, socket, ssl, and the full machine module for GPIO, I2C, SPI, UART, timers, ADC, and PWM.

Flashing MicroPython

Install esptool from pip:

1
pip install esptool

Download the MicroPython firmware for your chip from micropython.org/download. Each chip family has its own build — don’t mix them.

Find your board’s serial port:

1
2
3
4
5
6
7
# Linux/macOS
ls /dev/tty* | grep -i usb
# or
ls /dev/ttyACM*

# The device will appear as /dev/ttyUSB0 or /dev/ttyACM0 on Linux
# and /dev/cu.usbserial-* on macOS

Erase the flash, then write the firmware:

1
2
3
4
5
6
# Erase
esptool.py --chip esp32c6 --port /dev/ttyUSB0 erase_flash

# Flash (adjust firmware filename to match your download)
esptool.py --chip esp32c6 --port /dev/ttyUSB0 --baud 460800 \
  write_flash -z 0x0 ESP32_GENERIC_C6-20241129-v1.24.1.bin

For the original ESP32 substitute --chip esp32; for S3 use --chip esp32s3. After flashing, the board boots into the MicroPython REPL.

Connecting to the REPL

The simplest way to interact with the board is mpremote, which supersedes the older ampy and rshell tools:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pip install mpremote

# Open the REPL
mpremote connect /dev/ttyUSB0

# Run a file on the board
mpremote run main.py

# Copy a file to the board
mpremote cp main.py :main.py

# List files on the board
mpremote ls

In the REPL you have a live Python shell running on the microcontroller:

1
2
3
4
5
6
>>> import machine
>>> machine.freq()
160000000
>>> import os
>>> os.uname()
(sysname='esp32', nodename='esp32', release='1.24.1', version='v1.24.1 on 2024-11-29', machine='ESP32C6 module')

Press Ctrl-D for a soft reset. Press Ctrl-C to interrupt a running script.

Thonny IDE

For visual development and easier file management, Thonny (free, cross-platform) has first-class MicroPython support. It shows the board’s filesystem in a sidebar, lets you run scripts directly on the device, and includes a serial terminal. It’s worth having for debugging hardware issues even if you prefer editing in your regular editor.

Boot files

MicroPython runs two files at boot:

  • boot.py — Runs first. Put low-level hardware init and Wi-Fi connection here.
  • main.py — Runs after boot. Your application code.

If either file raises an exception, MicroPython drops to the REPL so you can diagnose. On a deployed device with no serial connection, exceptions in main.py will prevent your application from running — error handling and a watchdog timer are not optional on production deployments.


Wi-Fi Connection

Almost every homelab ESP32 project needs Wi-Fi. The standard pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import network
import time

def connect_wifi(ssid: str, password: str, timeout: int = 20) -> bool:
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)

    if wlan.isconnected():
        return True

    wlan.connect(ssid, password)

    deadline = time.time() + timeout
    while not wlan.isconnected():
        if time.time() > deadline:
            return False
        time.sleep(0.5)

    return True

# In boot.py
SSID = "your-ssid"
PASSWORD = "your-password"

if not connect_wifi(SSID, PASSWORD):
    import machine
    machine.reset()  # Reboot and try again

print("IP:", network.WLAN(network.STA_IF).ifconfig()[0])

Store credentials in a separate config.py that you do not commit to version control:

1
2
3
4
5
6
7
# config.py
WIFI_SSID = "your-ssid"
WIFI_PASSWORD = "your-password"
MQTT_BROKER = "192.168.1.10"
MQTT_USER = "mqtt"
MQTT_PASSWORD = "mqtt-password"
DEVICE_ID = "sensor-living-room"

GPIO: Digital I/O

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from machine import Pin
import time

# Digital output — LED on GPIO 8 (common on many boards)
led = Pin(8, Pin.OUT)
led.value(1)   # High
led.value(0)   # Low
led.toggle()   # Flip state

# Digital input with internal pull-up
button = Pin(9, Pin.IN, Pin.PULL_UP)
print(button.value())  # 1 when not pressed (pulled up), 0 when pressed

# Interrupt on button press
def button_handler(pin):
    print("Button pressed")

button.irq(trigger=Pin.IRQ_FALLING, handler=button_handler)

# PWM — LED fade
from machine import PWM
pwm = PWM(Pin(8), freq=1000)
for duty in range(0, 1024, 10):
    pwm.duty(duty)
    time.sleep_ms(5)
pwm.deinit()

I2C Sensors

I2C is the dominant protocol for small sensors. It uses two wires — SDA (data) and SCL (clock) — and supports up to 127 devices on a single bus. Every I2C device has a 7-bit address; common sensor addresses are documented in the datasheet.

ESP32                    Sensor
                         (BME280, SHT31, etc.)
  3.3V ──────────────── VCC
  GND ─────────────────  GND
  GPIO21 (SDA) ────────  SDA
  GPIO22 (SCL) ────────  SCL

Scan the bus to find connected device addresses:

1
2
3
4
5
6
from machine import I2C, Pin

i2c = I2C(0, sda=Pin(21), scl=Pin(22), freq=400000)
devices = i2c.scan()
print("I2C devices found:", [hex(d) for d in devices])
# ['0x76']  ← BME280 at address 0x76

BME280 — Temperature, Humidity, Pressure

The BME280 is the workhorse environmental sensor. About $3–5 on a breakout board, accurate, and well-supported. The BME680 adds a gas/air quality sensor at slightly higher cost; the BME688 adds AI classification for air quality at higher cost still.

Install the driver:

1
2
# Using mpremote with the MicroPython package index
mpremote mip install bme280

Or copy the driver manually:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# bme280.py — minimal driver, copy to board
# Full driver: https://github.com/robert-hh/BME280

import bme280
from machine import I2C, Pin

i2c = I2C(0, sda=Pin(21), scl=Pin(22), freq=400000)
bme = bme280.BME280(i2c=i2c)

temperature, pressure, humidity = bme.values
print(f"Temp: {temperature}°C  Pressure: {pressure}hPa  Humidity: {humidity}%")

SHT31 — Temperature and Humidity

The SHT31 is more accurate than a DHT22 and communicates over I2C rather than the DHT one-wire protocol. Worth the extra dollar for anything that will stay deployed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import struct

class SHT31:
    ADDR = 0x44

    def __init__(self, i2c):
        self.i2c = i2c

    def read(self):
        self.i2c.writeto(self.ADDR, b'\x2c\x06')  # High repeatability, no clock stretch
        import time; time.sleep_ms(50)
        data = self.i2c.readfrom(self.ADDR, 6)
        temp_raw = (data[0] << 8) | data[1]
        hum_raw  = (data[3] << 8) | data[4]
        temperature = -45 + (175 * temp_raw / 65535)
        humidity    = 100 * hum_raw / 65535
        return round(temperature, 2), round(humidity, 2)

from machine import I2C, Pin
i2c = I2C(0, sda=Pin(21), scl=Pin(22), freq=400000)
sensor = SHT31(i2c)
temp, hum = sensor.read()
print(f"{temp}°C  {hum}%")

Common I2C sensor addresses

Sensor Default address What it measures
BME280 / BME680 0x76 (SDO low) or 0x77 Temp, humidity, pressure, gas
SHT31 / SHT40 0x44 Temp, humidity
AHT20 / AHT21 0x38 Temp, humidity
BMP390 0x76 or 0x77 Pressure, temp (high precision)
VL53L0X 0x29 Time-of-flight distance
VEML7700 0x10 Ambient light lux
SGP30 / SGP41 0x58 VOC / air quality
INA219 / INA226 0x40–0x4F Current, voltage, power

MQTT: Publishing Sensor Data

MQTT is the standard protocol for IoT sensor data. A broker (typically Mosquitto, running on the same machine as Home Assistant or on a dedicated container) receives published messages and routes them to subscribers. Home Assistant can consume MQTT topics directly and turn them into sensors, switches, and devices.

Install the umqtt.simple module if it’s not already on the board:

1
mpremote mip install umqtt.simple

A complete sensor publish loop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# main.py
import time
import json
import machine
from machine import I2C, Pin
from umqtt.simple import MQTTClient
import bme280
import config  # your config.py

# Hardware setup
i2c = I2C(0, sda=Pin(21), scl=Pin(22), freq=400000)
sensor = bme280.BME280(i2c=i2c)
wdt = machine.WDT(timeout=30000)  # 30-second watchdog

def mqtt_connect() -> MQTTClient:
    client = MQTTClient(
        client_id=config.DEVICE_ID,
        server=config.MQTT_BROKER,
        user=config.MQTT_USER,
        password=config.MQTT_PASSWORD,
        keepalive=60,
    )
    client.connect()
    return client

def publish_readings(client: MQTTClient) -> None:
    temp, pressure, humidity = sensor.values
    payload = json.dumps({
        "temperature": float(temp.rstrip("C")),
        "humidity":    float(humidity.rstrip("%")),
        "pressure":    float(pressure.rstrip("hPa")),
        "device":      config.DEVICE_ID,
        "uptime":      time.ticks_ms() // 1000,
    })
    topic = f"homelab/sensors/{config.DEVICE_ID}/state"
    client.publish(topic, payload, retain=True)
    print(f"Published: {payload}")

# Main loop
client = mqtt_connect()
INTERVAL = 60  # seconds

while True:
    wdt.feed()  # Reset watchdog
    try:
        publish_readings(client)
    except OSError:
        # MQTT connection dropped — reconnect
        try:
            client = mqtt_connect()
        except Exception as e:
            print(f"Reconnect failed: {e}")
            machine.reset()  # Reboot if we can't reconnect

    time.sleep(INTERVAL)

The watchdog timer is essential. If the board locks up for any reason — a network stack hang, an unhandled exception, memory fragmentation — the watchdog fires and reboots it. A deployed sensor that hangs silently for weeks is worse than one that reboots and recovers.


Home Assistant: MQTT Discovery

MQTT Discovery is the mechanism by which Home Assistant automatically creates sensors, switches, and devices from MQTT messages without any manual YAML configuration. A device publishes a discovery message to a well-known topic; Home Assistant reads it and creates the entity.

The discovery topic pattern:

homeassistant/<component>/<device_id>/<object_id>/config

Publish this once at boot (with retain=True so Home Assistant picks it up even after a HA restart):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def publish_discovery(client: MQTTClient) -> None:
    base = f"homelab/sensors/{config.DEVICE_ID}"

    sensors = [
        {
            "name":        f"{config.DEVICE_ID} Temperature",
            "unique_id":   f"{config.DEVICE_ID}_temp",
            "device_class": "temperature",
            "unit_of_measurement": "°C",
            "value_template": "{{ value_json.temperature }}",
        },
        {
            "name":        f"{config.DEVICE_ID} Humidity",
            "unique_id":   f"{config.DEVICE_ID}_hum",
            "device_class": "humidity",
            "unit_of_measurement": "%",
            "value_template": "{{ value_json.humidity }}",
        },
        {
            "name":        f"{config.DEVICE_ID} Pressure",
            "unique_id":   f"{config.DEVICE_ID}_pres",
            "device_class": "pressure",
            "unit_of_measurement": "hPa",
            "value_template": "{{ value_json.pressure }}",
        },
    ]

    device_info = {
        "identifiers": [config.DEVICE_ID],
        "name":        config.DEVICE_ID,
        "model":       "ESP32-C6 BME280",
        "manufacturer": "DIY",
    }

    for s in sensors:
        discovery_payload = {
            **s,
            "state_topic": f"{base}/state",
            "device":      device_info,
            "availability_topic": f"{base}/availability",
        }
        topic = f"homeassistant/sensor/{config.DEVICE_ID}/{s['unique_id']}/config"
        client.publish(topic, json.dumps(discovery_payload), retain=True)

    # Mark device as online
    client.publish(f"{base}/availability", "online", retain=True)

After this runs, open Home Assistant’s Devices & Services page — under MQTT you will find a new device with all three sensors auto-populated and ready to add to dashboards or use in automations.


MicroPython vs ESPHome

These are different tools for different situations. Understanding the trade-off saves you from picking the wrong one.

MicroPython ESPHome
Language Python YAML (with C++ lambda escape hatch)
Setup overhead Low for Python devs Very low for simple sensors
Flexibility Full programmability Constrained to what ESPHome supports
Custom logic Arbitrary Python Limited; lambdas are verbose
HA integration Via MQTT (manual) or ESPHome native API Native, zero-config, auto-discovery
OTA updates Via webrepl or mpremote Built-in, OTA from HA dashboard
Debugging REPL, print statements ESPHome logs via web UI or HA
Memory footprint Larger (Python runtime) Smaller (compiled C++)
Community resources Large Python community Large HA/ESPHome community
Good for Custom logic, non-HA integrations, learning Standard sensors wired to HA

Use MicroPython when:

  • You need custom business logic that doesn’t map cleanly to ESPHome’s component model
  • You’re integrating with something other than Home Assistant (your own MQTT pipeline, a REST API, a database)
  • You want to write real code rather than configure YAML
  • You’re learning embedded programming and want transferable skills
  • The project involves multi-step state machines, complex timing, or non-standard protocols

Use ESPHome when:

  • You’re building a standard sensor node (temperature, humidity, motion, door contact) that feeds Home Assistant
  • You want automatic OTA updates managed from the HA dashboard
  • The sensor will be deployed and forgotten — ESPHome’s zero-config HA discovery is more reliable at scale than manually maintained MQTT topics
  • You want your non-programmer household members to be able to see device status

For a homelab, the practical answer is usually: use ESPHome for simple, permanent sensors (temperature nodes, door/window contacts, motion detectors), and MicroPython for anything that needs real logic — a custom display driver, a sensor that publishes to your own Prometheus endpoint, a controller that reads multiple sensors and drives an output based on derived calculations.

A minimal ESPHome config for comparison

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# living-room-sensor.yaml
esphome:
  name: living-room-sensor
  friendly_name: Living Room Sensor

esp32:
  board: esp32-c6-devkitc-1
  framework:
    type: esp-idf

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome
    password: !secret ota_password

i2c:
  sda: GPIO21
  scl: GPIO22

sensor:
  - platform: bme280_i2c
    address: 0x76
    temperature:
      name: "Temperature"
    pressure:
      name: "Pressure"
    humidity:
      name: "Humidity"
    update_interval: 60s

Flash with: esphome run living-room-sensor.yaml

That’s the entire configuration. Home Assistant discovers the device automatically. Compare the code surface to the MicroPython version — ESPHome wins on simplicity for this use case.


Power Management and Battery Operation

Mains-powered sensors can poll and publish continuously without concern. Battery-powered sensors — door contacts, outdoor weather nodes, anything where running a cable is impractical — need deep sleep.

The ESP32’s deep sleep current is around 10–20 µA. A 1000 mAh LiPo cell powering a sensor that wakes every 5 minutes, reads a sensor, and publishes over Wi-Fi (typically 100–200 mA for 2–3 seconds) will last many months.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import machine
import time
import network
import json
from umqtt.simple import MQTTClient
import bme280
import config

SLEEP_SECONDS = 300  # 5 minutes

def main():
    # Connect Wi-Fi
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(config.WIFI_SSID, config.WIFI_PASSWORD)

    deadline = time.ticks_add(time.ticks_ms(), 10000)
    while not wlan.isconnected():
        if time.ticks_diff(deadline, time.ticks_ms()) <= 0:
            # Wi-Fi failed — sleep and try again next cycle
            machine.deepsleep(SLEEP_SECONDS * 1000)
        time.sleep_ms(100)

    # Read sensor
    from machine import I2C, Pin
    i2c = I2C(0, sda=Pin(21), scl=Pin(22), freq=400000)
    sensor = bme280.BME280(i2c=i2c)
    temp, pressure, humidity = sensor.values

    # Publish
    client = MQTTClient(config.DEVICE_ID, config.MQTT_BROKER,
                        user=config.MQTT_USER, password=config.MQTT_PASSWORD)
    client.connect()

    payload = json.dumps({
        "temperature": float(temp.rstrip("C")),
        "humidity":    float(humidity.rstrip("%")),
        "pressure":    float(pressure.rstrip("hPa")),
    })
    client.publish(f"homelab/sensors/{config.DEVICE_ID}/state", payload, retain=True)
    client.disconnect()

    # Sleep until next reading
    machine.deepsleep(SLEEP_SECONDS * 1000)

main()

With deep sleep, main.py runs from the top on every wake. There is no persistent loop — execution ends with deepsleep() and resumes at the top of main.py after the sleep interval. Keep the wake path short and fast; every millisecond awake costs battery.

A few deep sleep caveats:

  • GPIO pin state is not preserved across deep sleep unless you use RTC GPIO and store state in RTC memory
  • Wi-Fi association takes 2–4 seconds on each wake. Using a static IP instead of DHCP shaves ~1 second off every wake cycle
  • Store persistent values (counters, calibration data) in esp32.RTC memory: import esp32; esp32.RTC().memory(b'data')

Practical Project Recipes

Whole-house temperature map

Six ESP32-C6 boards, each with a BME280, scattered through the house. Each publishes to homelab/sensors/<room>/state. A Home Assistant dashboard shows a floor plan with real-time temperature and humidity per room. Total hardware cost under $40.

Boot.py: connect Wi-Fi
Main.py: publish discovery → publish readings → sleep 5m

Door and window contact sensor

A reed switch wired between a GPIO pin (with internal pull-up) and GND. The pin reads LOW when the magnet is near (door closed) and HIGH when the magnet moves away (door open). With deep sleep and event-driven wake via ext0 wakeup pin, a battery can last a year.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import machine, esp32, network, json
from umqtt.simple import MQTTClient

DOOR_PIN = machine.Pin(9, machine.Pin.IN, machine.Pin.PULL_UP)
state = "closed" if DOOR_PIN.value() == 0 else "open"

# connect, publish state, sleep
# Wake on pin change:
esp32.wake_on_ext0(DOOR_PIN, esp32.WAKEUP_ANY_HIGH)
machine.deepsleep()

Relay controller for a dumb appliance

A relay module (5V coil, 10A contacts) controlled by a GPIO pin lets an ESP32 switch mains-voltage devices. Pair with an MQTT subscription and you have a remotely controllable switch.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from umqtt.simple import MQTTClient
from machine import Pin
import json

relay = Pin(5, Pin.OUT, value=0)  # Off by default

def on_message(topic, msg):
    data = json.loads(msg)
    relay.value(1 if data.get("state") == "ON" else 0)

client = MQTTClient(...)
client.set_callback(on_message)
client.subscribe(b"homelab/relays/kitchen-fan/set")

while True:
    client.check_msg()  # Non-blocking check for new messages
    wdt.feed()
    time.sleep_ms(100)

OTA Updates with MicroPython

The mpremote tool handles one-off file pushes over USB. For sensors deployed around the house without USB access, webrepl provides Wi-Fi-based file transfer.

Enable webrepl in boot.py:

1
2
import webrepl
webrepl.start(password="yourpassword")

Then connect from a browser at ws://<device-ip>:8266 or use the webrepl_cli.py script:

1
python webrepl_cli.py -p yourpassword main.py 192.168.1.42:/main.py

For fleet-scale OTA, the ota_updater pattern checks a remote URL for a version file and downloads updated modules if the version has changed. This is more fragile than ESPHome’s OTA implementation — if a bad update bricks the board and you can’t get to it physically, you need to reflash via serial. For permanent installations, ESPHome’s OTA is more reliable.


Tooling Summary

Tool Purpose
esptool.py Flash firmware to the chip
mpremote File transfer, REPL access, package install
Thonny Visual IDE with built-in MicroPython support
mip (built into mpremote) MicroPython package manager
ESPHome YAML-configured firmware for HA-integrated sensors
Mosquitto MQTT broker (runs in Docker alongside HA)
MQTT Explorer GUI for inspecting MQTT topics during development

The development loop for a new sensor build: flash MicroPython, open Thonny, prototype in the REPL, move working code into main.py, push via mpremote, iterate. Once the design is stable and you want easier OTA for permanent deployment, consider rewriting the simplest sensors in ESPHome.


Sources:

Comments