Desktop Buddy

Arquitectura del firmware

ESP-IDF v6.1 · target esp32s3 · núcleo C++ + scripting Berry · app 1,29 MB de 3 MB
§1 La única regla

Todo es un evento en el bus

Ningún driver llama nunca a otro driver. Un Sense publica; lo que le importe se suscribe. Una sola tarea drena la cola, así que los handlers corren en un único contexto y nunca necesitan locks propios. Esta única decisión es la razón de que se pueda añadir un sensor nuevo sin tocar una línea del código existente.

Las suscripciones casan con un nombre exacto (touch.pet), una familia (touch.*) o con todo (* — así escuchan el tracer de depuración y el host de Berry). Los eventos publicados dentro de un handler caen en el siguiente drenado, no en el actual, lo que evita que las cascadas recursen.

Publicar
Traza serie
Elige un evento arriba para verlo propagarse. El borde izquierdo marca el núcleo en el que corre la tarea.
§2 Componentes

Cuatro primitivas, un componente cada una

Cada funcionalidad tiene que presentarse como un Sense (entrada → eventos), una Expression (acciones → salida), un Reflex (lógica evento → acción, en script) o una Skill (capacidad mediada por el LLM, v2+). La estructura de directorios sigue la misma división.

ComponenteTipoPublicaSe suscribe a
bus/núcleo——
senses/touch_senseSensetouch.down · touch.pet · touch.poke—
senses/rc522Sensenfc.tag—
expressions/round_faceExpression—face.emotion · face.say · face.look · boot.*
expressions/led_ringExpression—face.emotion · led.mood
brain/brain_cloudBrainbrain.reply · brain.error · led.moodbrain.ask
berry_hostReflexes(lo que emitan los scripts)* · system.reload
webuinúcleosystem.reload · time.synced—
maincableadoface.* · led.mood · boot.*brain.reply · *(traza)

Fíjate en lo que main es: no un objeto-dios, solo cableado más el único reflejo propiedad del framework, el que convierte una respuesta del Brain en expresiones. Todo lo demás es un componente que se podría borrar sin romper a sus vecinos. El contrato completo — con dueños por prefijo — está en docs/event-registry.md.

§3 Concurrencia

Siete tareas en dos núcleos

La red y TLS viven en el núcleo 0 junto al despachador del bus; todo lo que debe ir fluido — el renderizado, los sensores, el anillo LED — vive en el núcleo 1. La regla general: cualquier cosa que pueda bloquear durante segundos va al núcleo 0 y habla con el resto del sistema solo a través del bus.

TareaNúcleoStackPrioPeriodoPor qué existe
main081921una vezlevanta cada subsistema y termina
bus06144510 ms ociosodrena la cola; todos los handlers corren aquí
brain0102404bloqueanteTLS + HTTP a Claude; stack profundo para mbedTLS
face16144440 msparpadeo, sacada, render, push del frame
ring13072340 msanimación de respiración / cometa en el WS2812
touch13072425 msalmohadilla capacitiva, antirrebote de 2 muestras
rc522130724200 mssondea tags NFC por SPI

El Brain nunca bloquea el bus: los handlers de brain.ask solo hacen strdup del prompt a una cola de 4 y vuelven. Si la cola está llena la pregunta se descarta, en vez de atascar a la criatura entera.

§4 Portabilidad

Lo que la segunda placa nos enseñó del renderizado

Durante un tiempo hubo dos targets con un solo código: el ESP32-S3 de referencia y el ESP32 clásico, que corría la misma cara a color. El clásico se retiró — sin PSRAM no hay caché de ojos ni modelo local —, pero lo que obligó a descubrir sigue siendo cierto del firmware que quedó, y por eso esta sección sigue aquí.

El hallazgo fue que los 460,8 KB que la cara parece necesitar no son el renderizado. Son cuatro sprites de 240×240 a 16 bpp, 115.200 bytes cada uno: tres son la caché de niveles de ojo —345.600 bytes, y viven en PSRAM porque solo se blitean—, que convierte un parpadeo en tres blits en vez de tres renders. El cuarto es el frame de trabajo, y ese vive en RAM interna, que es donde más rápido se dibuja. Dibujar solo necesita ese frame, y un frame se podía construir por bandas horizontales de 240×48 (23 KB) en lugar de los 115 KB de la pantalla entera. El clásico dibujaba así, a 14,4 fps medidos, y el S3 usaba el mismo código con una sola banda del alto completo.

Esa distinción —caché contra frame— es la que sigue mandando en el S3, y el día que la PSRAM se llene con el modelo local, el que tiene que ceder es el caché y no el dibujado. Hoy eso es una intención con media implementación, y conviene saber qué media: si los tres sprites no caben en el momento en que arranca la cara, face_start() devuelve los que sí cupieron y el renderizador dibuja los ojos en cada frame en vez de blitearlos —40-81 ms por frame contra 27,9, o sea 12-25 fps, y el buddy conserva la cara. Pero face_start() corre pronto (main.cpp, antes de la red), así que ante una presión posterior el que cede es el modelo, no el caché. Que la caché ceda de verdad pide un accesor que hoy no existe. El código por bandas se retiró con el target (gravaba cada rutina de dibujo con un sesgo de coordenada), pero la propiedad que lo hacía posible se conserva: todo efecto del glitch es row-local y ninguna rutina lee una fila distinta de la que escribe.

§5 El Brain

Un contrato, tres cerebros posibles

El firmware nunca nombra a un proveedor de IA. Publica brain.ask y espera brain.reply. Hoy un adaptador en el dispositivo cumple ese contrato llamando a Claude Haiku directamente por TLS; mañana un servidor hub puede servir el contrato idéntico; sin red alguna, nadie lo cumple y los reflejos simplemente siguen corriendo.

Por defecto · hoy

Adaptador en dispositivo

WiFi + una API key. Una conexión TLS persistente, reutilizada entre preguntas.

Opcional · v2

Servidor hub

Mismo contrato por LAN. Desbloquea webhooks, integraciones OAuth y memoria a largo plazo.

Siempre

Sin cerebro

Sin red, los reflejos siguen disparando. El buddy está más callado, nunca roto.

// device → brain
{ "event": "touch.pet", "personality": "…", "sensors": {…} }

// brain → device (se parsea a la defensiva: al modelo le encantan las vallas
// ```json, así que el framework extrae el {…} más exterior en vez de fiarse)
{ "utterance": "All forgiven!", "emotion": "happy" }

En esta ruta viven dos detalles ganados a pulso: el parser de respuestas tolera vallas de markdown, y la conexión TLS se mantiene viva porque un handshake nuevo cuesta ~1,9 s medidos de matemática de curva elíptica por software — suficiente para hambrear a IDLE0, que es justo por lo que el watchdog de tareas está en 15 s.

§6 El comportamiento es datos

Las emociones son una tabla, no una ruta de código

Una emoción son cinco enteros, un temperamento de parpadeo y un color. El renderer y el anillo LED leen la misma fila, y por eso los ojos y el halo no pueden discrepar nunca. Pulsa una fila — los ojos de abajo los dibuja el mismo algoritmo que corre el firmware.

EmociónAnAlAbreGuiñoCejaParpadeo
neutral

Por encima de la tabla está la capa que nadie recompila: los reflejos Berry. Los scripts se suben desde la propia web del dispositivo, que escribe el archivo y publica system.reload; el host derriba la VM y la reconstruye en sitio. Editar comportamiento nunca implica un cable de flasheo.

# packs/zero/reflexes/main.be — recarga en caliente, sin recompilar
buddy.on("touch.pet", def (ev)
  buddy.face.emotion("happy")
  buddy.led.mood("excited")
  buddy.ask("The user just petted you gently.")   # → brain.ask
end)

La VM vive entera en la tarea de despacho del bus, así que los scripts no necesitan locks — y, en igual medida, un script que entre en bucle infinito congela todos los reflejos. Esa restricción es el precio de la simplicidad.

§7 Presupuestos

A dónde se va la memoria

Imagen de aplicación · ESP32-S3
1,29 MB usadospartición de 3 MB

Incluye la VM de Berry, mbedTLS y la pila gráfica.

RAM interna · stacks de tareas
≈ 39 KB entre 7 tareasde 512 KB de SRAM

Más unos 50 KB adicionales mientras hay una sesión TLS abierta.

El frame de trabajo de 240×240 (115 KB) vive en RAM interna — es ~2× más rápida de dibujar que la PSRAM y cabe; lo que va a PSRAM son las tres cachés de nivel de ojo (3 × 115 KB). Los buffers de DMA y WiFi deben quedarse siempre en RAM interna. Dos crashes enseñaron estos límites por las malas: app_main desbordando su stack por defecto de 3584 bytes al levantar la pantalla y la VM a la vez, y un desbordamiento del buffer de ajuste de texto que corrompía el heap con respuestas largas. Ambos están arreglados y documentados en las notas de hardware para que nadie los reaprenda.

§8 Extender

Añadir algo nuevo

Comportamiento nuevo

Escribe un reflejo

Unas líneas de Berry en el editor web. Sin toolchain, sin recompilar, vivo en segundos.

Sensor nuevo

Añade un Sense

Un .cpp, una tarea y bus().publish(…). Nada existente cambia.

Salida nueva

Añade una Expression

Suscríbete a un evento de acción y mueve el hardware. El mismo aislamiento.

Los límites honestos del diseño actual, que conviene conocer antes de construir encima: la tabla de emociones sigue compilada dentro en vez de venir de un pack; la llamada al Brain no es en streaming, así que la cara no puede reaccionar a media frase; y todavía no hay ruta de audio — los chirps y el bucle de voz push-to-talk son el siguiente hito. Los agujeros conocidos del bus están listados en docs/event-registry.md.