📡 STRIDE — Dispositivo IoT Modular para ESP32¶
STRIDE es el firmware base de un dispositivo IoT modular desarrollado como Trabajo de Fin de Grado (TFG) sobre ESP-IDF y ESP32. Su objetivo es ofrecer una alternativa versátil y reconfigurable a los dispositivos IoT comerciales: el núcleo central integra WiFi (AP+STA), un servidor HTTP, pantalla TFT, lector de tarjeta SD y un bus I2C de expansión, y delega la lógica de usuario a scripts .str escritos en un DSL propio que se interpretan en el dispositivo.
La pieza clave de la arquitectura es que añadir hardware, endpoints, vistas o comandos del DSL no requiere tocar el núcleo: cada extensión vive en un módulo aislado y se conecta al sistema a través de un puñado de patrones bien definidos.
🗂️ Estructura del repositorio¶
.
├── CMakeLists.txt # Proyecto ESP-IDF (declara los EXTRA_COMPONENT_DIRS)
├── partitions.csv # Tabla de particiones de la flash
├── sdkconfig* # Configuración de ESP-IDF
└── core/
├── src/ # Componente principal: lógica del dispositivo
│ ├── main.cc # app_main(): bootstrap y creación de tareas FreeRTOS
│ ├── blackboard.hpp # Estado global compartido (config + observables)
│ ├── brain/ # Subsistemas centrales (network, server, display, …)
│ ├── handler/ # Endpoints HTTP (uno por archivo)
│ ├── tasks/ # Funciones de tarea FreeRTOS
│ ├── types/ # Enums y tipos compartidos
│ └── utils/ # Helpers (parse, timer, sink, hexer)
├── plugin/ # Componentes reutilizables (cada uno es un IDF component)
│ ├── io/ # Drivers de periféricos: LED, botón, buzzer (StrideBase)
│ ├── lang/ # Lexer + parser (AST) del DSL .str
│ ├── logger/ # Logger por subsistema
│ ├── observer/ # StrideObservable / StrideSubscription
│ └── locator/ # Service locator tipado
├── external/ # Dependencias vendored (LovyanGFX)
└── tests/ # Tests unitarios sobre PC
core/src/CMakeLists.txt registra core/src como componente principal y declara como dependencias (REQUIRES) los plugins (logger, io, observer, lang, locator). El CMakeLists.txt raíz expone los tres directorios de componentes (core/src, core/external, core/plugin) vía EXTRA_COMPONENT_DIRS.
🧠 Arquitectura del código¶
El sistema se organiza en capas, cada una con una responsabilidad clara y con dependencias solo hacia abajo:
┌──────────────────────────────────────────────┐
│ main.cc (app_main, bootstrap, FreeRTOS) │
└──────────────────────────────────────────────┘
│
┌──────────────────────────────┼──────────────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ tasks/ │ │ brain/ │ │ handler/ │
│ FreeRTOS │ ──────────► │ Subsistemas │ ◄────────── │ Endpoints HTTP │
│ (loops) │ │ (Network, │ │ (Ping, Root, │
│ │ │ Server, │ │ Editor, Wifi…) │
│ │ │ Display, │ │ │
│ │ │ Interpreter)│ │ │
└──────────────┘ └──────────────┘ └──────────────────┘
│ │ │
└─────────────┬──────────────┴──────────────┬───────────────┘
▼ ▼
┌──────────────────┐ ┌────────────────────┐
│ blackboard.hpp │ │ core/plugin/ │
│ (estado global + │ │ io · observer · │
│ observables) │ │ logger · locator │
└──────────────────┘ │ · lang │
└────────────────────┘
Patrones transversales¶
Toda la arquitectura se apoya en cinco patrones que conviene entender antes de tocar código:
| Patrón | Dónde vive | Para qué sirve |
|---|---|---|
| Blackboard | core/src/blackboard.hpp | Estructura static con la configuración y el estado global del dispositivo (pines, puertos, IPs, modo actual…). Cualquier capa puede leerla. |
| Observable | stride_observer.hpp | StrideObservable<T> envuelve un valor y notifica a sus subscriptores cuando cambia. Es la forma estándar de comunicar cambios de estado entre tareas (p. ej. Blackboard::CurrentServerMode). |
| Service Locator | stride_locator.hpp | Registro tipado de servicios (StrideLocator::Register<T> / Get<T>). Evita pasar punteros a mano entre subsistemas. |
| Handler (HTTP) | handler.hpp | Interfaz común para endpoints HTTP. Cada Handler expone uno o varios httpd_uri_t* y el Server los registra automáticamente. |
| Logger por subsistema | stride_logger.hpp | StrideLogger::Log(StrideSubsystem::X, …) produce logs con un tag estable por área (Network, Server, Interpreter…). |
Subsistemas (core/src/brain/)¶
- network/ — Inicializa WiFi (modo AP + STA), persiste credenciales en NVS, controla el LED de red.
- server/ — Servidor HTTP. Mantiene la lista de
Handlers y los registra enhttpd. La funciónload_handlers()decide qué endpoints están activos segúnBlackboard::CurrentServerMode(Developer vs Production). - display/ — Pantalla TFT (LovyanGFX) gestionada con una máquina de estados (
DisplayBaseState:Startup,Main,View,Running). El singletonDisplay::Instance()posee el TFT y el estado activo; las transiciones se hacen conDisplay::transition_to(...). - interpreter/ — Intérprete del DSL
.str. Singleton tree-walking que recorre el AST producido por el parser delangy ejecuta las sentencias sobre el hardware (LEDs, botones, buzzer, expansor, I2C). Registra los bloqueswhen/everycomo handlers y, al acabar el flujo secuencial, entra en un bucle de despacho de eventos. - expander/ — Bus I2C maestro para los módulos expansores. La abstracción de pin lógico del expansor PCF8574 (
pin <nombre> on expander <n>en el DSL) vive en tasks/expander_task.cc; el intérprete los expone como dispositivos normales en su registro unificado (turn/toggle/lectura). - bus/ — Constantes y
spi_sd_init()para SD y TFT. - apps/ —
AppManagerescanea la SD y mantiene la lista de programas disponibles.
Tareas FreeRTOS (core/src/tasks/)¶
app_main (core/src/main.cc:14) crea las tareas y las fija a un core concreto:
- Core 0 — sistema, WiFi, HTTP, monitorización:
hear_server_mode_button_task,open_card_task. - Core 1 — UI y cómputo:
hear_program_selected_file_button_task,read_card_task(intérprete),display_task(dueño del bus SPI2 del TFT).
[!IMPORTANT] Cualquier tarea que dibuje en el TFT o llame a
Display::transition_to(...)debe correr en el Core 1, porque comparte la propiedad del bus SPI condisplay_task.
Plugins (core/plugin/)¶
Cada plugin es un componente IDF independiente con su propio CMakeLists.txt. Esto los hace reusables y testeables aislados:
- io/ — Drivers de periféricos heredados de
StrideBase(StrideLed,StrideButton,StrideBuzzer). Encapsulan la API de GPIO de ESP-IDF. - observer/ — Plantillas
StrideObservable<T>yStrideSubscription(RAII). - logger/ — Logger con enumerado
StrideSubsystem. - locator/ — Mapa
type_index → void*para inyección de servicios. - lang/ — Lenguaje del DSL: lexer (
lang::lex()) y parser (lang::parse()→ ASTlang::Program+ lista de errores con línea). C++ puro sin dependencias de ESP-IDF, por eso se testea en PC.
DSL .str y archivos en SD¶
El usuario final no programa C++: escribe scripts .str que se almacenan en la SD y se editan vía la web (/editor, /view, /browser). El flujo es: AppManager escanea la SD → la pantalla muestra los programas → al seleccionar uno, read_card_task lee el archivo completo, lo parsea con lang::parse() y, si no hay errores, lo ejecuta con Interpreter. Si hay errores de sintaxis, se listan con su línea en el log y el programa no se ejecuta (el endpoint /save hace la misma validación al guardar). La referencia del lenguaje está en docs/dsl.md.
➕ Cómo añadir código nuevo¶
A continuación, las recetas más habituales. Todas siguen un mismo principio: localiza la capa adecuada y respeta sus patrones en lugar de añadir lógica al núcleo.
1. Añadir un endpoint HTTP¶
Crea un nuevo Handler siguiendo el patrón de ping.hpp / ping.cc.
core/src/handler/include/<nombre>.hpp— hereda deHandler, expónuris()y declara un handler estáticostatic esp_err_t handler(httpd_req_t *req).core/src/handler/<nombre>.cc— implementa el constructor (rellena elhttpd_uri_tcon.uri,.method,.handlery.user_ctx = this) y la función estática (recuperathisdesdereq->user_ctx).- Regístralo en
Server::load_handlers()(server.cc:77) conthis->add_handler(new MiHandler());. Si el endpoint solo debe existir en modo desarrollador, mételo dentro delif (Blackboard::CurrentServerMode.get() == ServerMode::Developer). - Aumenta
Blackboard::MaxHandlerssi te quedas sin slots (config.max_uri_handlers).
No hace falta tocar el CMakeLists.txt: el file(GLOB_RECURSE ...) de core/src/CMakeLists.txt recoge los .cc nuevos automáticamente.
2. Añadir una tarea FreeRTOS¶
core/src/tasks/include/mi_task.hpp— declara la firmavoid mi_task(void *pvParameters);.core/src/tasks/mi_task.cc— implementa el bucle. UsavTaskDelay(pdMS_TO_TICKS(...))para ceder CPU yStrideLogger::Log(...)para trazas.- Lánzala desde
app_main(core/src/main.cc) conxTaskCreatePinnedToCore(...), eligiendo el core según la regla anterior (UI/TFT → core 1, resto → core 0). Dimensiona el stack en base a las tareas existentes (4–8 KiB es lo habitual). - Si la tarea reacciona a un cambio de estado, suscríbete a un observable del Blackboard en vez de hacer polling:
Blackboard::CurrentServerMode.subscribe([](auto& m){ ... });.
3. Añadir un nuevo periférico (LED, sensor, etc.)¶
Los drivers viven en core/plugin/io/ y todos derivan de StrideBase.
- Crea
stride_<dispositivo>.hppencore/plugin/io/include/heredando deStrideBase, implementastart()y la API pública del periférico (on/off/toggle/set/geto equivalente). - Crea
stride_<dispositivo>.ccencore/plugin/io/. - El
file(GLOB ...)de core/plugin/io/CMakeLists.txt lo recoge solo. - Úsalo desde cualquier subsistema o tarea:
StrideLed led(GPIO_NUM_26);. Si el pin debe ser configurable, declaralo en el Blackboard (sección "Global buttons" o similar) en vez de hardcodearlo.
Si el periférico es complejo o tiene su propio stack (p. ej. otro bus), considera crear un plugin nuevo (carpeta hermana de io/, con su CMakeLists.txt y su idf_component_register) y añadirlo al REQUIRES de core/src/CMakeLists.txt:30.
4. Añadir una pantalla/estado al display¶
La pantalla es una máquina de estados. Cada estado implementa DisplayBaseState.
- Añade el valor al enum
StateTypeen core/src/types/enums.hpp. - Crea
core/src/brain/display/include/<mi>_state.hppcon una clase que herede deDisplayBaseState(on_enter,on_update,on_exit,get_type, y opcionalmenteon_input). - Implementa el
.cccorrespondiente; usactx.getTFT()para dibujar. - Para transicionar:
Display::Instance().transition_to(std::make_unique<MiState>());desde una tarea del Core 1.
5. Añadir un comando al DSL (.str)¶
- Lexer — si el comando necesita una palabra clave nueva, añade el
TokKinden core/plugin/lang/include/lang_token.hpp, su entrada en el mapakeywordsy entok_kind_name()de lexer.cc. Si la palabra solo tiene sentido dentro de tu comando (comoregisterointo), déjala comoNamey reconócela contextualmente en el parser (accept_word("...")): así sigue siendo usable como nombre de variable. - Parser — añade el
Stmt::Kinden lang_ast.hpp y una funciónparse_mi_comando()en parser.cc, enganchada alswitchdeparse_statement_into(). Reporta los errores conerror(line, "mensaje claro en español"). - Tests — cubre el comando (forma válida + errores) en core/tests/parser_test.cc; corre
make && ./run_testsencore/tests(no necesita ESP-IDF). - Intérprete — añade el caso en el
switchdeexec_stmt()(interpreter_exec.cc) e implementa la ejecución. Usaeval(expr)para los argumentos yruntime_error(line, ...)para los fallos en caliente. - Si el comando habla con I2C, sigue el patrón de interpreter_i2c.cc. Si opera sobre el expansor PCF8574, usa
expander_pin_write/expander_pin_readde expander_task.hpp — mantienen una shadow copy del byte del chip. - Documentación — actualiza docs/dsl.md y su espejo HTML embebido en core/src/handler/librarie.cc.
6. Añadir configuración global¶
Si la nueva funcionalidad expone un parámetro (un pin, un timeout, un puerto…):
- Añádelo como
static inlineen la sección correspondiente del Blackboard. - Si su cambio debe propagarse en caliente, declaralo como
StrideObservable<T>y suscríbete donde haga falta. - Si debe persistir entre reinicios, guárdalo en NVS siguiendo el patrón de
Network::save_net_credentials.
7. Exponer un servicio compartido entre subsistemas¶
Cuando dos subsistemas necesitan compartir una instancia sin acoplamiento directo, usa el Locator en vez de pasar punteros por constructor:
StrideLocator::Register<MiServicio>(&instancia); // dueño lo publica
auto* svc = StrideLocator::Get<MiServicio>(); // consumidor lo recupera
🌐 Configuración de conectividad¶
El dispositivo arranca con un fallback estático (sección ESPAP/LOCAL para desarrollo) y, en cuanto el usuario guarda credenciales por la web, las persiste en NVS y se conecta en automático en los siguientes reinicios.
{
"ESPAP": { "SSID": "STRIDE_Dev_AP", "PASS": "stride1234" },
"LOCAL": { "SSID": "Mi_Red_Pruebas", "PASS": "password_pruebas" }
}
Flujo del usuario final:
- SoftAP — el ESP32 levanta su propia red WiFi al no encontrar credenciales válidas.
- Web de configuración — el usuario se conecta a esa red e introduce SSID/PASS de su red local.
- NVS — las credenciales se persisten en flash.
- Auto-conexión — los siguientes arranques saltan directamente a modo STA.
Gestión de archivos (Web UI)¶
/browser— listado de los.stren la raíz de la SD./editor?file=...— editor en navegador para modificar scripts en caliente./view— visor solo lectura para depuración rápida.
[!TIP] Optimización de memoria: los endpoints de lectura (
/view,/editor) usan chunked transfer, así que pueden servir archivos grandes sin saturar la heap del ESP32.