SmartPot-IoT-Python is the MicroPython firmware companion for rapid prototyping and simulation: MicroPython 1.22.0 on an ESP32 (1.23+ is supported too, but newer images restarted in Wokwi), simulated in Wokwi or flashed onto a physical board. For production physical deployments, see the canonical SmartPot-IoT Rust no_std firmware. It reads sensors, displays values on an LCD screen, publishes telemetry over MQTT over TLS, and executes commands arriving from SmartPot while acknowledging each one. If the network drops, it continues measuring, displaying, and turning off active actuators on schedule.
With a physical board or Wokwi, the crop is real to SmartPot: its readings come from this firmware and train continuous learning. No hardware or Wokwi open? Create a virtual crop in the PWA—it is simulated by SmartPot-DataGenerator using the same contract and cannot be converted into a real crop later.
flowchart LR
sensors["DHT22 · light · pH<br/>TDS · substrate"] --> esp["ESP32<br/>MicroPython"]
esp --> lcd["20x4 LCD<br/>readings & connection"]
esp -->|"telemetry · status · ack"| broker["mqtt.smartpot.app:8883<br/>TLS"]
broker -->|"commands"| esp
esp --> act["Pump · UV light · Fan"]
| Component | ESP32 Pin | Reported Scale |
|---|---|---|
| DHT22 (air temperature and humidity) | GPIO 15 | °C and % |
| Light sensor (potentiometer in Wokwi) | GPIO 34 | 0–2000 lux |
| pH sensor | GPIO 35 | 0–14 |
| TDS sensor | GPIO 32 | 0–3000 ppm |
| Substrate moisture | GPIO 33 | 0–100 % |
| Water pump (blue LED) | GPIO 19 | WATER_PUMP |
| UV light (purple LED) | GPIO 18 | UV_LIGHT |
| Fan (orange LED) | GPIO 5 | FAN |
| 20x4 I2C LCD | SCL 16 · SDA 17 | — |
SmartPot-IoT/
├── fs/ # ESP32 filesystem
│ ├── main.py # Boot: welcome screen, WiFi, NTP clock, and device loop
│ ├── device.py # Loop: measures and turns off on schedule; publishes and obeys when online
│ ├── smartpot_client.py # MQTT v1 contract and TLS verified with the CA in config.json
│ ├── sensors.py # Analog sensors and DHT22
│ ├── actuators.py # Actuators with timed automatic shutoff
│ ├── display.py # LCD display: readings, actuators, and connection state
│ ├── utils.py # NTP clock sync and console table with reading timestamp
│ ├── settings.py # Loads config.json (shared format with the Rust firmware)
│ ├── config.example.json # Template for config.json (untracked) with embedded broker CA
│ └── i2c_lcd.py, lcd_api.py
├── tests/ # CPython tests with simulated MicroPython modules
├── diagram.json # Wokwi circuit with MicroPython 1.22.0
├── wokwi.toml # Local simulation: build/flasher_args.json (MicroPython 1.22.0 + fs/)
├── tools/build_flash.py # Builds build/ with fs/ and config.json in MicroPython's LittleFS
├── start.py # Alternative: mounts fs/ via mpremote without rebuilding the image
└── pyproject.toml / uv.lock # Development tooling (littlefs-python, mpremote, pytest, ruff)
The device connects to mqtt.smartpot.app:8883 over TLS (1.2 or newer), verifies the broker certificate with SmartPot's public CA, and authenticates with username = crop ID and password = device key. The client ID is smartpot-<cropId> (the broker rejects empty IDs).
| Topic | Direction | Example |
|---|---|---|
smartpot/v1/{cropId}/telemetry |
Publishes every 30 s | {"temperature":23.5,"humidity":61,"brightness":820,"ph":6.12,"tds":790,"soilMoisture":64.2} |
smartpot/v1/{cropId}/commands |
Subscribes (QoS 1) | {"id":"…","actuator":"WATER_PUMP","action":"ACTIVATE","durationSeconds":15} |
smartpot/v1/{cropId}/commands/ack |
Publishes (QoS 1) | {"id":"…","status":"EXECUTED","message":"Water pump turned on for 15 s"} |
smartpot/v1/{cropId}/status |
Retained & Last Will | online / offline |
smartpot/v1/{cropId}/capabilities |
Publishes after online (retained, QoS 1) |
{"schema":1,"device":{"firmware":"smartpot-iot-python/2.0.0","hardware":"esp32-devkit-c-v4","telemetry":{"formats":["json-v1"]}},"sensors":[…],"actuators":["WATER_PUMP","UV_LIGHT","FAN"]} |
With durationSeconds, the actuator turns off automatically once the timer expires; without it, it stays on until receiving DEACTIVATE.
The device declares its six sensors and three actuators in .../capabilities after status=online and before subscribing to commands; the message is retained, uses schema 1, does not declare atmosphere or compact-v2, and stays under 2 KB. brightness and tds are published as JSON integers, never as 820.0 or 790.0. Command validation is strict: id must be a non-empty string (null, booleans, numbers, lists and objects are rejected), actuator and action must be non-empty strings, and durationSeconds must be a non-negative integer or absent. Commands are deduplicated by id over the last 16, so a QoS 1 redelivery repeats the previous ACK without toggling the hardware again.
Wokwi does not support .crt files, so the public CA is stored as PEM text inside config.json (BROKER.CA_CRT), the only device-specific file. The firmware:
- converts it to DER, which mbedtls reads across all MicroPython builds;
- passes it to
umqtt.simpleas expected by each version:ssl_paramswithcadatain MicroPython 1.22 and anSSLContextin 1.23+ (umqtt.simplein 1.22 ignoresSSLContextand encrypts without verifying); - verifies the broker certificate and hostname after synchronizing the clock via NTP, because the ESP32 validates certificate expiration dates;
- refuses to connect without a valid CA: it never sends the device key to an unverified broker.
The loop runs every half second with or without network: it turns off timed actuators on schedule, reads sensors every interval_seconds, and shows them on the LCD and in the console with the reading timestamp. When connected to the broker, it also publishes telemetry and handles commands; every new broker session starts with a fresh reading. If the connection drops, it retries the broker and WiFi with exponential backoff and jitter (2 s to 60 s). The LCD redraws at once when an actuator or the broker link changes; its top-right corner shows an antenna when online and an X when offline.
- Actuator safety policy (shared with the Rust firmware and the contract fixture):
durationSecondsmust be an integer between 1 and 3600, otherwise the command gets aFAILEDACK with the reason. The water pump never runs without a limit:ACTIVATEwithout a duration uses 3600 s, and it switches off after 300 s without a broker session. The UV light and the fan keep indefinite activation.active_low: Truesupports relay boards that switch on with a low input; GPIO5 (fan) is a strapping pin that toggles at boot, so add a pull-down on the relay input. - Watchdog:
machine.WDT(watchdog_seconds, default 30) is fed from the safety timer only while the loop keeps beating. If the loop stalls for 120 s, the outputs are switched off,SMARTPOT-FATAL watchdog task=loop stalledis printed and the board restarts; if the interpreter hangs, the hardware watchdog restarts it.0disables both while debugging from the REPL. Outputs are switched off first at boot and on any fatal error, which restarts the board. - Never blocked by the network: ACKs are published after
check_msgreturns, the socket times out after 15 s, the device pings the broker every 30 s (a ping left unanswered closes the session at the next one), and any client error counts as a lost connection. A hardware timer turns timed actuators off even while the loop waits on the network, and timers use a monotonic clock so NTP never stretches them. - Resilient LCD: telemetry is published before drawing; a missing or failing LCD prints
LCD not available at 0x27once, is replaced by a no-op and probed again every 10 s, and only changed rows are redrawn. - Sensors: every sensor is sampled every 2 s (the DHT22 minimum) and a value stays valid for three periods, so one
failed DHT22 read does not blank the table. Each analog channel averages 16 raw 12-bit reads and scales them as
raw / 4095, exactly like the Rust firmware (read_u16approximates that scale and shifted the second decimal). - Same output as the Rust firmware:
tests/fixtures/console_v1.json(identical in SmartPot-IoT) pins the console messages, the reading table, the LCD rows, the analog scale and the command rejection reasons for both firmwares. - Memory:
gc.collect()runs before every TLS connection,SMARTPOT-HEAPreports the free heap (heap_min_freeprintsSMARTPOT-HEAP-LOWbelow a threshold), and repeated out-of-memory connection failures restart the board. - Plain-text markers for automated tests:
SMARTPOT-READY,WIFI-UP,MQTT-UP,MQTT-DOWN,ACT,ACK,LCD,HEAPandFATAL.
In smartpot.app, create a real crop and choose its vessel type (pot, NFT pipes, vertical tower, or floating raft). The application displays the device key only once along with the connection guide (physical ESP32 or Wokwi) and the ready-to-use config.json containing the WiFi network, the crop ID and the broker CA; if you lose the key, generate a new one from the Device tab.
cp fs/config.example.json fs/config.jsonFill in WIFI, crop_id, and device_key (the guide values) in config.json; the BROKER block already contains the broker CA. Both firmwares read the same config.json format, so you can paste the guide's block as is. config.json is in .gitignore: never commit the device key to the repository or leave it visible in a public Wokwi project.
Open the Wokwi project, replace its files with those in fs/ (including your config.json with the CA) and diagram.json (which uses MicroPython 1.22.0, the same image as wokwi.toml), and run. The Wokwi-GUEST network has Internet access. The firmware also runs and verifies the broker on 1.23 or newer, but those images kept restarting in Wokwi.
uv sync
cp config.example.json fs/config.json # once; or the block from the connection guide
uv run python tools/build_flash.py # again after every change in fs/
# start the Wokwi simulation: it boots straight into main.pytools/build_flash.py writes build/flasher_args.json: the official MicroPython 1.22.0 image at 0x1000 and a
LittleFS image with fs/*.py and fs/config.json at 0x200000, the offset of the vfs partition. MicroPython
mounts it at boot and runs main.py by itself, like the Rust firmware with its ELF. build/ is not versioned because
it carries the device key. To try a change without rebuilding, start the simulation and run uv run python start.py
once the console shows >>> or the firmware is running: it stops main.py, mounts fs/ via mpremote (port 4000)
and runs it from your computer.
uv run ruff check .
uv run pytestThe suite contains 135 tests and verifies the MQTT contract against the shared v1 fixture (sensor/actuator names and the legacy LIGHTS alias), integer telemetry and capability declaration, strict command validation, 16-id deduplication and ACK handling, timed actuator shutoff (including offline), offline loop and retry intervals, sensor scaling, LCD display, console timestamp table, and broker verification with the DER CA under both MicroPython 1.22 and 1.23+ umqtt.simple signatures.
The firmware only speaks MQTT with the broker; everything else is decided by the platform. Component documentation is in docs/ (also in DOCX and PDF), with diagrams in docs/diagrams. The technical documentation details the MQTT contract, TLS connection, and circuit:
- Interactive diagrams: ESP32 pins and firmware modules, in English and Spanish, with links to the exact source lines
- Complete operation
- State machines
- Complete architecture
This project is licensed under the MIT License.
SmartPot-IoT-Python es el firmware compañero en MicroPython para prototipado rápido y simulación: MicroPython 1.22.0 sobre un ESP32 (también admite 1.23 o posterior, pero esas imágenes se reiniciaban en Wokwi), simulado en Wokwi o grabado en una placa física. Para despliegues físicos en producción, consulta el firmware canónico en Rust no_std SmartPot-IoT. Lee los sensores, muestra los valores en una pantalla LCD, publica la telemetría por MQTT sobre TLS y ejecuta los comandos que llegan desde SmartPot, confirmando cada uno. Si se cae la red, sigue midiendo, mostrando y apagando a tiempo lo que esté encendido.
Con placa o en Wokwi, el cultivo es real para SmartPot: sus lecturas vienen de este firmware y entrenan el aprendizaje continuo. ¿Sin hardware ni Wokwi abierto? Crea en la PWA un cultivo virtual: lo simula SmartPot-DataGenerator con el mismo contrato, y no se puede convertir en real después.
flowchart LR
sensors["DHT22 · light · pH<br/>TDS · substrate"] --> esp["ESP32<br/>MicroPython"]
esp --> lcd["20x4 LCD<br/>readings & connection"]
esp -->|"telemetry · status · ack"| broker["mqtt.smartpot.app:8883<br/>TLS"]
broker -->|"commands"| esp
esp --> act["Pump · UV Light · Fan"]
| Componente | Pin ESP32 | Escala enviada |
|---|---|---|
| DHT22 (temperatura y humedad del aire) | GPIO 15 | °C y % |
| Sensor de luz (potenciómetro en Wokwi) | GPIO 34 | 0–2000 lux |
| Sensor de pH | GPIO 35 | 0–14 |
| Sensor de TDS | GPIO 32 | 0–3000 ppm |
| Humedad del sustrato | GPIO 33 | 0–100 % |
| Bomba de agua (LED azul) | GPIO 19 | WATER_PUMP |
| Luz ultravioleta (LED morado) | GPIO 18 | UV_LIGHT |
| Ventilador (LED naranja) | GPIO 5 | FAN |
| LCD 20x4 I2C | SCL 16 · SDA 17 | — |
SmartPot-IoT/
├── fs/ # Sistema de archivos del ESP32
│ ├── main.py # Arranque: bienvenida, WiFi, hora y el ciclo del dispositivo
│ ├── device.py # Ciclo: mide y apaga a tiempo siempre; publica y obedece con conexión
│ ├── smartpot_client.py # Contrato MQTT v1 y TLS verificado con la CA de config.json
│ ├── sensors.py # Sensores analógicos y DHT22
│ ├── actuators.py # Actuadores con apagado automático por duración
│ ├── display.py # Pantalla LCD: lecturas, actuadores y estado de la conexión
│ ├── utils.py # Hora por NTP y tabla por consola con la hora de cada lectura
│ ├── settings.py # Carga config.json (formato común con el firmware Rust)
│ ├── config.example.json # Plantilla de config.json (no se versiona) con la CA del broker en texto
│ └── i2c_lcd.py, lcd_api.py
├── tests/ # Pruebas con CPython y módulos de MicroPython simulados
├── diagram.json # Circuito de Wokwi con MicroPython 1.22.0
├── wokwi.toml # Simulación local: build/flasher_args.json (MicroPython 1.22.0 + fs/)
├── tools/build_flash.py # Genera build/ con fs/ y config.json en el LittleFS de MicroPython
├── start.py # Alternativa: monta fs/ con mpremote sin regenerar la imagen
└── pyproject.toml / uv.lock # Herramientas de desarrollo (littlefs-python, mpremote, pytest, ruff)
El dispositivo se conecta a mqtt.smartpot.app:8883 con TLS (1.2 o superior), verifica el certificado del broker con la CA pública de SmartPot y se autentica con usuario = id del cultivo y la clave del dispositivo. El client id es smartpot-<cropId> (el broker rechaza ids vacíos).
| Tópico | Sentido | Ejemplo |
|---|---|---|
smartpot/v1/{cropId}/telemetry |
Publica cada 30 s | {"temperature":23.5,"humidity":61,"brightness":820,"ph":6.12,"tds":790,"soilMoisture":64.2} |
smartpot/v1/{cropId}/commands |
Recibe (QoS 1) | {"id":"…","actuator":"WATER_PUMP","action":"ACTIVATE","durationSeconds":15} |
smartpot/v1/{cropId}/commands/ack |
Publica (QoS 1) | {"id":"…","status":"EXECUTED","message":"Water pump turned on for 15 s"} |
smartpot/v1/{cropId}/status |
Retenido y última voluntad | online / offline |
smartpot/v1/{cropId}/capabilities |
Publica tras online (retenido, QoS 1) |
{"schema":1,"device":{"firmware":"smartpot-iot-python/2.0.0","hardware":"esp32-devkit-c-v4","telemetry":{"formats":["json-v1"]}},"sensors":[…],"actuators":["WATER_PUMP","UV_LIGHT","FAN"]} |
Con durationSeconds el actuador se apaga solo al cumplirse el tiempo; sin él queda encendido hasta recibir DEACTIVATE.
El dispositivo declara sus seis sensores y tres actuadores en .../capabilities después de status=online y antes de suscribirse a commands; el mensaje es retenido, usa el esquema 1, no declara atmosphere ni compact-v2 y ocupa menos de 2 KB. brightness y tds se publican como enteros JSON, nunca como 820.0 o 790.0. La validación de comandos es estricta: id debe ser una cadena no vacía (se rechazan null, booleanos, números, listas y objetos), actuator y action deben ser cadenas no vacías, y durationSeconds debe ser un entero no negativo o estar ausente. Los comandos se deduplican por id en los últimos 16, así que un reenvío QoS 1 repite el ACK anterior sin accionar el hardware otra vez.
Wokwi no admite archivos .crt, así que la CA pública viaja como texto en config.json (BROKER.CA_CRT), el único archivo propio de cada dispositivo. El firmware:
- la convierte a DER, que mbedtls lee en cualquier compilación de MicroPython;
- se la entrega a
umqtt.simplecomo lo pide cada versión:ssl_paramsconcadataen MicroPython 1.22 y unSSLContextdesde 1.23 (elumqtt.simplede 1.22 ignora elSSLContexty cifra sin verificar); - verifica el certificado y el nombre del broker, y antes sincroniza la hora por NTP, porque el ESP32 comprueba las fechas del certificado;
- sin una CA válida no se conecta: nunca entrega la clave a un broker sin verificar.
El ciclo corre cada medio segundo con o sin red: apaga a tiempo los actuadores, lee los sensores cada interval_seconds y los muestra en la LCD y en la consola, con la hora de la lectura. Con el broker conectado, además publica la telemetría y atiende los comandos; cada sesión nueva con el broker empieza con una lectura fresca. Si la conexión se cae, reintenta el broker y el WiFi con espera exponencial y dispersión (de 2 s a 60 s). La pantalla se redibuja al momento cuando cambia un actuador o la conexión con el broker; su esquina muestra una antena en línea y una equis sin conexión.
- Política de actuadores (compartida con el firmware Rust y con el fixture del contrato):
durationSecondsdebe ser un entero entre 1 y 3600; si no, el comando recibe un ACKFAILEDcon el motivo. La bomba de agua nunca queda encendida sin límite: unACTIVATEsin duración usa 3600 s y se apaga tras 300 s sin sesión con el broker. La luz UV y el ventilador conservan el encendido indefinido.active_low: Trueadmite módulos de relé que se activan con nivel bajo; GPIO5 (ventilador) es un pin de strapping que conmuta al arrancar, así que conviene un pull-down en la entrada del relé. - Watchdog:
machine.WDT(watchdog_seconds, 30 por defecto) se alimenta desde el temporizador de seguridad solo mientras el ciclo late. Si el ciclo se detiene 120 s, se apagan las salidas, se imprimeSMARTPOT-FATAL watchdog task=loop stalledy la placa se reinicia; si se cuelga el intérprete, la reinicia el watchdog de hardware.0desactiva ambos para depurar desde el REPL. Las salidas se apagan antes que nada al arrancar y ante cualquier error fatal, que reinicia la placa. - La red nunca bloquea el ciclo: los ACK se publican cuando
check_msgtermina, el socket tiene un tiempo límite de 15 s, el dispositivo envía PINGREQ cada 30 s (un PINGREQ sin respuesta cierra la sesión en el siguiente) y cualquier error del cliente cuenta como conexión perdida. Un temporizador de hardware apaga los actuadores temporizados aunque el ciclo esté esperando a la red, y los temporizadores usan un reloj monótono para que NTP nunca los alargue. - LCD resistente: la telemetría se publica antes de dibujar; un LCD ausente o averiado imprime una vez
LCD not available at 0x27, se sustituye por uno nulo y se vuelve a buscar cada 10 s, y solo se reescriben las filas que cambian. - Sensores: todos se muestrean cada 2 s (el mínimo del DHT22) y un valor sigue siendo válido durante tres periodos,
así que una lectura fallida del DHT22 no deja la tabla en blanco. Cada canal analógico promedia 16 lecturas crudas de
12 bits y las escala como
raw / 4095, igual que el firmware Rust (read_u16aproxima esa escala y movía el segundo decimal). - Misma salida que el firmware Rust:
tests/fixtures/console_v1.json(idéntico en SmartPot-IoT) fija los mensajes de consola, la tabla de lecturas, las filas del LCD, la escala analógica y los motivos de rechazo de comandos de los dos firmwares. - Memoria:
gc.collect()se ejecuta antes de cada conexión TLS,SMARTPOT-HEAPinforma del heap libre (heap_min_freeimprimeSMARTPOT-HEAP-LOWpor debajo de un umbral) y los fallos de memoria repetidos al conectar reinician la placa. - Marcadores de texto para pruebas automáticas:
SMARTPOT-READY,WIFI-UP,MQTT-UP,MQTT-DOWN,ACT,ACK,LCD,HEAPyFATAL.
En smartpot.app crea un cultivo real y elige su forma (maceta, tubos NFT, torre o balsa). La aplicación muestra una sola vez la clave del dispositivo junto con la guía de conexión (ESP32 físico o Wokwi) y el config.json listo, con la red WiFi, el id del cultivo y la CA del broker; si pierdes la clave, genera una nueva desde la pestaña Dispositivo.
cp fs/config.example.json fs/config.jsonCompleta en config.json WIFI, crop_id y device_key (los valores de la guía); el bloque BROKER ya trae la CA del broker. Ambos firmwares leen el mismo formato de config.json, así que puedes pegar tal cual el bloque de la guía. config.json está en .gitignore: nunca subas la clave al repositorio ni la dejes visible en un proyecto público de Wokwi.
Abre el proyecto de Wokwi, reemplaza sus archivos por los de fs/ (con tu config.json, que ya trae la CA) y por el diagram.json, que usa MicroPython 1.22.0 (la misma imagen que wokwi.toml), y ejecuta. La red Wokwi-GUEST tiene salida a Internet. El firmware también funciona y verifica el broker en 1.23 o posterior, pero esas imágenes se reiniciaban en Wokwi.
uv sync
cp config.example.json fs/config.json # una vez; o el bloque de la guía de conexión
uv run python tools/build_flash.py # otra vez tras cada cambio en fs/
# inicia la simulación de Wokwi: arranca directamente en main.pytools/build_flash.py escribe build/flasher_args.json: la imagen oficial de MicroPython 1.22.0 en 0x1000 y una
imagen LittleFS con fs/*.py y fs/config.json en 0x200000, el offset de la partición vfs. MicroPython la monta
al arrancar y ejecuta main.py solo, como el firmware Rust con su ELF. build/ no se versiona porque lleva la clave del
dispositivo. Para probar un cambio sin regenerar la imagen, inicia la simulación y ejecuta uv run python start.py
cuando la consola muestre >>> o el firmware ya esté corriendo: detiene main.py, monta fs/ con mpremote
(puerto 4000) y lo ejecuta desde tu computador.
uv run ruff check .
uv run pytestLa suite contiene 135 pruebas y verifica el contrato MQTT contra la fixture v1 compartida (nombres de sensores y actuadores y el alias heredado LIGHTS), la telemetría entera y la declaración de capacidades, la validación estricta de comandos, la deduplicación de los últimos 16 ids y el ACK, el apagado por tiempo de los actuadores (también sin conexión), el ciclo sin red con sus reintentos, la escala de los sensores, la pantalla, la tabla con la hora y la verificación del broker con la CA en DER, tanto con el umqtt.simple de MicroPython 1.22 como con el de 1.23 en adelante.
El firmware solo habla MQTT con el broker; todo lo demás lo decide la plataforma. Su documentación propia está en docs/ (también en DOCX y PDF), con sus diagramas en docs/diagrams. La documentación técnica detalla el contrato MQTT, la conexión con TLS y el circuito.
- Diagramas interactivos: los pines del ESP32 y los módulos del firmware, en español e inglés, con enlaces a las líneas exactas del código
Este proyecto está bajo la licencia MIT.