Hay disfraces que se ven bien en la foto y hay disfraces que la gente recuerda. La diferencia casi siempre está en el movimiento. Estos cuernos de papel maché tienen LEDs adentro que laten como brasas cuando estás quieta, se cargan de rojo intenso cuando inclinas la cabeza, y disparan una llamarada que sube desde la base hasta la punta cuando la sacudes. No hay control remoto ni app: el propio acelerómetro lee tus gestos.

Al terminar vas a tener un accesorio portátil con animación reactiva, vas a entender cómo se construyen animaciones de fuego con matemática simple (una sinusoide, un decaimiento exponencial y un degradado por posición), y vas a saber portar código de CircuitPython escrito para una placa con sensores integrados a una combinación de componentes sueltos que sí se consigue en Chile.

Cuernos LED terminados con animación de brasas

Nota sobre las piezas: qué cambia respecto del original

El proyecto original se apoya en una placa Adafruit RP2040 Prop Maker Feather, que trae acelerómetro LIS3DH, carga de batería y bornera de tornillo, todo en una sola PCB. Es una placa cómoda, pero no se vende en Chile. La versión que sigue arma lo mismo con tres piezas separadas:

Original Versión chilena Qué cambia
RP2040 Prop Maker Feather Raspberry Pi Pico Mismo chip RP2040, mismo CircuitPython. No trae acelerómetro ni carga de batería.
LIS3DH integrado MPU-6050 GY-521 También I2C, también entrega m/s². Cambia la librería y la línea de inicialización.
NeoPixel 1515 de 4 mm Tira WS2812B 5050 Mismo protocolo y mismo código; la tira es más ancha, así que los cuernos quedan un poco más gruesos.
Carga por USB de la placa Módulo TP4056 USB-C Se recarga la batería sin desarmar nada, igual que en el original.

Nada de esto cambia el proyecto: cambia el cableado y tres líneas de código.

Placa RP2040 con tira LED conectada a la bornera

Cómo se cablean las dos tiras

Los dos cuernos llevan tiras en paralelo, no en cadena. Es decir: la misma señal de datos entra a las dos, y por eso las dos muestran exactamente la misma animación al mismo tiempo, en espejo. Es más simple que encadenarlas y evita que el segundo cuerno vaya retrasado.

De cada tira salen tres cables que van al Pico:

  • +5V de las tiras → VBUS o VSYS del Pico
  • GND de las tiras → GND del Pico (y al GND de la batería: tierra común, siempre)
  • DI (data in) de las dos tiras → GP0 del Pico

El MPU-6050 va por I2C: VCC a 3V3, GND a GND, SDA a GP4, SCL a GP5.

Tira NeoPixel WS2812B mostrando una animación de arcoíris

Los dos detalles eléctricos que el original no necesita explicar

La placa original resuelve esto por hardware; con el Pico hay que resolverlo a mano y conviene saberlo antes de soldar:

1. Niveles lógicos. El RP2040 saca 3.3 V y el WS2812B alimentado a 5 V espera un "1" lógico por encima de unos 3.5 V. En la práctica funciona en la gran mayoría de los casos con cables cortos. que es exactamente nuestro caso, 20 cm hasta la cabeza. pero si ves el primer pixel parpadeando en colores raros, ese es el síntoma. La solución barata es alimentar la tira desde la batería de 3.7 V en vez de 5 V: los WS2812B encienden perfecto a ese voltaje, se ven levemente menos brillantes, y el problema de niveles desaparece porque la diferencia entre 3.3 V y 3.7 V es despreciable.

2. Resistencia y condensador. Una resistencia de 330 a 470 Ω en serie con la línea de datos protege el primer pixel de los picos al conectar, y un condensador de 470 a 1000 µF entre + y, de la tira absorbe el golpe de corriente del arranque. Con 42 pixeles no es obligatorio, pero cuesta menos de mil pesos y evita quemar el primer LED de la tira, que es justo el más difícil de reemplazar porque está en la base del cuerno.

Cuánta batería consume esto realmente

Este cálculo tampoco está en el original y define si el disfraz aguanta la fiesta. Un WS2812B a pleno blanco tira unos 60 mA, así que 42 pixeles al máximo serían 2.5 A. muchísimo. Pero la animación de brasas nunca enciende a fondo: el color base es (255, 24, 3) escalado a un brillo de entre 0.10 y 0.28, y encima se desvanece hacia la punta. El consumo real ronda los 120 a 180 mA.

Con una batería de 600 mAh eso da del orden de 3 a 5 horas de uso continuo, con las llamaradas incluidas. Si necesitas más, baja BRIGHTNESS de 0.9 a 0.5: casi no se nota en la oscuridad y estiras la autonomía cerca de un 40 %.

Batería de litio 3.7V con conector JST

El código, por partes

El programa está escrito en CircuitPython, así que no se compila: copias el archivo code.py a la unidad CIRCUITPY que aparece al conectar la placa y se ejecuta solo. Para instalarlo, descarga el .uf2 de CircuitPython para tu placa desde circuitpython.org, mantén presionado BOOTSEL mientras la conectas y arrastra el archivo a la unidad RPI-RP2.

Primero, la configuración y el arranque del hardware. Acá están todos los números que vas a querer tocar:

Python
# SPDX-FileCopyrightText: 2026 Erin St Blaine for Adafruit Industries
#
# SPDX-License-Identifier: MIT

# Motion-Reactive Devil Horns
# Quiet ember pulse at rest
# Fast traveling flare on sharp movement
# Progressive demon charge on head tilt
#
# Adafruit RP2040 Prop-Maker Feather
#
# Pixel 0 = horn base
# Last pixel = horn tip

import math
import random
import time

import adafruit_lis3dh
import board
import digitalio
import neopixel


# ---------------------------------------------------------------------------
# CONFIGURATION
# ---------------------------------------------------------------------------

PIXEL_COUNT = 21
BRIGHTNESS = 0.9
FRAME_DELAY = 0.02

# Ember pulse timing.
EMBER_PULSE_SPEED = 1.4

# Small random flickers near the base.
EMBER_FLICKER_CHANCE = 8
EMBER_FLICKER_DECAY = 0.88

# Sharp movement required to trigger the traveling flare.
FLARE_TRIGGER = 6.0
FLARE_COOLDOWN = 0.9

# Traveling flare behavior.
FLARE_STEP_TIME = 0.02
FLARE_WIDTH = 5

# Head tilt behavior.
#
# X axis is near 0 when your head is level.
# Tilt begins affecting the horns around this value.
TILT_TRIGGER = 3.0

# Around this X reading, the horn is fully charged.
TILT_FULL = 5.0

# Lower = slower/smoother response.
# Higher = faster response.
TILT_SMOOTHING = 0.10


# ---------------------------------------------------------------------------
# POWER AND NEOPIXEL SETUP
# ---------------------------------------------------------------------------

external_power = digitalio.DigitalInOut(board.EXTERNAL_POWER)
external_power.direction = digitalio.Direction.OUTPUT
external_power.value = True

pixels = neopixel.NeoPixel(
    board.EXTERNAL_NEOPIXELS,
    PIXEL_COUNT,
    brightness=BRIGHTNESS,
    auto_write=False,
    pixel_order=neopixel.GRB,
)


# ---------------------------------------------------------------------------
# ACCELEROMETER SETUP
# ---------------------------------------------------------------------------

i2c = board.I2C()
accelerometer = adafruit_lis3dh.LIS3DH_I2C(i2c)
accelerometer.range = adafruit_lis3dh.RANGE_4_G

previous_x, previous_y, previous_z = accelerometer.acceleration

tilt_level = 0.0
last_flare_time = -FLARE_COOLDOWN


# ---------------------------------------------------------------------------
# EMBER STATE
# ---------------------------------------------------------------------------

ember_flicker = [0.0] * PIXEL_COUNT


# ---------------------------------------------------------------------------
# FLARE STATE
# ---------------------------------------------------------------------------

flare_active = False
flare_position = 0.0
last_flare_step = time.monotonic()

Las tres líneas que hay que cambiar para el Pico están en este bloque. board.EXTERNAL_POWER y board.EXTERNAL_NEOPIXELS son nombres que solo existen en la placa Prop Maker: en el Pico no hay pin de alimentación conmutada, así que las dos líneas de external_power se eliminan y el pin de datos pasa a ser board.GP0. Y la inicialización del acelerómetro cambia de adafruit_lis3dh a adafruit_mpu6050, donde el objeto se crea con adafruit_mpu6050.MPU6050(i2c) y la propiedad de lectura sigue llamándose acceleration, con lo que el resto del programa queda intacto. Como el MPU-6050 arranca en ±2 g por defecto y el original trabaja en ±4 g, conviene subir el rango con accelerometer_range para que los sacudones fuertes no saturen.

También ajusta PIXEL_COUNT al número real de LEDs que cortaste de la tira. Con una tira de 60 LED/m, 35 cm por cuerno son 21 pixeles, que es justo el valor del original.

Después vienen los ayudantes de color. Son las funciones que hacen que esto se vea como fuego y no como un LED RGB cualquiera:

Python
def scale_color(color, amount):
    """Scale an RGB color by a brightness amount."""

    amount = max(0.0, min(1.0, amount))

    return tuple(
        min(255, int(channel * amount))
        for channel in color
    )


def blend_colors(base_color, overlay_color, amount):
    """Blend two RGB colors."""

    amount = max(0.0, min(1.0, amount))

    return tuple(
        int(base + (overlay - base) * amount)
        for base, overlay in zip(base_color, overlay_color)
    )


def ember_color(position, pulse_amount):
    """Return the resting ember color for one pixel."""

    # Fade strongly from the horn base toward the tip.
    position_fade = max(
        0.0,
        1.0 - position / (PIXEL_COUNT - 1)
    )

    # Concentrate most of the glow in the lower half.
    position_fade = position_fade ** 1.8

    # Gentle breathing glow.
    brightness = (
        0.10
        + pulse_amount * 0.18
    ) * position_fade

    # Dark red with a trace of orange.
    color = (255, 24, 3)

    return scale_color(color, brightness)


def flare_color(strength):
    """Create the bright yellow-orange motion flare."""

    strength = max(0.0, min(1.0, strength))

    red = 255
    green = int(100 + 155 * strength)
    blue = int(8 + 120 * strength)

    return (
        red,
        min(255, green),
        min(135, blue),
    )


def charge_color(strength):
    """Red-orange glow for the sustained head-tilt charge."""

    strength = max(0.0, min(1.0, strength))

    red = 255
    green = int(30 + 90 * strength)
    blue = int(2 + 12 * strength)

    return (
        red,
        green,
        blue,
    )

Fíjate en ember_color: el position_fade ** 1.8 es el truco visual del proyecto. Un degradado lineal se vería como una linterna; ese exponente concentra el brillo en la parte baja del cuerno y deja la punta casi apagada, que es exactamente cómo se ve un carbón encendido.

La lectura de movimiento distingue dos cosas distintas a partir del mismo sensor: cambio brusco (la magnitud de la diferencia entre lecturas consecutivas, que dispara la llamarada) e inclinación sostenida (el valor absoluto del eje X, que carga el color):

Python
def read_motion_and_tilt():
    """Measure sharp motion and update the head-tilt level."""
    # pylint: disable=global-statement

    global previous_x, previous_y, previous_z
    global tilt_level

    x, y, z = accelerometer.acceleration

    delta_x = x - previous_x
    delta_y = y - previous_y
    delta_z = z - previous_z

    previous_x = x
    previous_y = y
    previous_z = z

    acceleration_change = math.sqrt(
        delta_x * delta_x
        + delta_y * delta_y
        + delta_z * delta_z
    )

    # Head tilt uses the X axis.
    # abs() means left and right tilts behave identically.
    tilt_amount = abs(x)

    raw_tilt = (
        tilt_amount - TILT_TRIGGER
    ) / (TILT_FULL - TILT_TRIGGER)

    raw_tilt = max(0.0, min(1.0, raw_tilt))

    # Smooth the charge so it grows and recedes rather than jittering.
    tilt_level += (
        raw_tilt - tilt_level
    ) * TILT_SMOOTHING

    return acceleration_change

El TILT_SMOOTHING = 0.10 es lo que separa un efecto elegante de uno epiléptico: en vez de saltar al valor nuevo, cada cuadro se acerca solo un 10 % hacia él. Es un filtro pasabajos de una línea.

Las animaciones de brasa y de llamarada viajera:

Python
def update_embers():
    """Update small, irregular ember flickers."""

    for index in range(PIXEL_COUNT):
        ember_flicker[index] *= EMBER_FLICKER_DECAY

    # Occasional subtle flare-ups near the horn base.
    if random.random() < EMBER_FLICKER_CHANCE / 100.0:
        flicker_pixel = random.randint(0, min(4, PIXEL_COUNT - 1))
        ember_flicker[flicker_pixel] = random.uniform(0.15, 0.45)

        # Let some flicker spill into the next pixel.
        if flicker_pixel + 1 < PIXEL_COUNT:
            ember_flicker[flicker_pixel + 1] = max(
                ember_flicker[flicker_pixel + 1],
                ember_flicker[flicker_pixel] * 0.45,
            )


# ---------------------------------------------------------------------------
# TRAVELING FLARE
# ---------------------------------------------------------------------------

def start_traveling_flare():
    """Start a bright flare at the horn base."""
    # pylint: disable=global-statement

    global flare_active
    global flare_position
    global last_flare_step

    flare_active = True
    flare_position = -2.0
    last_flare_step = time.monotonic()


def update_traveling_flare():
    """Move the flare toward the horn tip."""
    # pylint: disable=global-statement

    global flare_active
    global flare_position
    global last_flare_step

    if not flare_active:
        return

    current_time = time.monotonic()

    elapsed_steps = int(
        (current_time - last_flare_step) / FLARE_STEP_TIME
    )

    if elapsed_steps > 0:
        flare_position += elapsed_steps
        last_flare_step += elapsed_steps * FLARE_STEP_TIME

    # End after the flare tail has fully moved past the tip.
    if flare_position - FLARE_WIDTH > PIXEL_COUNT - 1:
        flare_active = False


def flare_amount_for_pixel(index):
    """Return flare brightness for a particular pixel."""

    if not flare_active:
        return 0.0

    distance = flare_position - index

    if distance < 0 or distance > FLARE_WIDTH:
        return 0.0

    normalized = 1.0 - distance / FLARE_WIDTH

    # Keeps the flare front broad and bright.
    return normalized ** 0.45


# ---------------------------------------------------------------------------
# HEAD-TILT CHARGE
# ---------------------------------------------------------------------------

def charge_amount_for_pixel(index):
    """Return how strongly the tilt effect affects this pixel."""

    if tilt_level <= 0.0:
        return 0.0

    # As tilt increases, the glow climbs farther toward the horn tip.
    fill_position = tilt_level * (PIXEL_COUNT + 1)

    distance = fill_position - index

    if distance <= 0:
        return 0.0

    # Pixels comfortably below the fill line get the full tilt strength.
    if distance >= 2.0:
        return tilt_level

    # Soft leading edge.
    return tilt_level * (distance / 2.0)

Y el dibujado, que combina las tres capas. brasa de base, carga por inclinación y llamarada. en cada pixel antes de mandarlas a la tira:

Python
def draw_pixels():
    """Draw ember pulse, tilt charge, and motion flare."""

    current_time = time.monotonic()

    # Slow breathing pulse.
    pulse_wave = (
        math.sin(current_time * EMBER_PULSE_SPEED) + 1.0
    ) / 2.0

    # Keep the pulse gentle rather than fading completely out.
    pulse_amount = 0.25 + pulse_wave * 0.75

    for index in range(PIXEL_COUNT):
        base_color = ember_color(
            index,
            pulse_amount,
        )

        # Add small independent flickers.
        if ember_flicker[index] > 0:
            flicker_color = scale_color(
                (255, 48, 4),
                ember_flicker[index],
            )

            base_color = tuple(
                min(
                    255,
                    base_color[channel] + flicker_color[channel],
                )
                for channel in range(3)
            )

        # ---------------------------------------------------------------
        # HEAD-TILT CHARGE
        # ---------------------------------------------------------------

        charge_amount = charge_amount_for_pixel(index)

        if charge_amount > 0:
            charge_overlay = charge_color(charge_amount)

            base_color = blend_colors(
                base_color,
                charge_overlay,
                0.20 + charge_amount * 0.70,
            )

        # ---------------------------------------------------------------
        # TRAVELING FLARE
        # ---------------------------------------------------------------

        flare_amount = flare_amount_for_pixel(index)

        if flare_amount > 0:
            overlay = flare_color(flare_amount)

            overlay_amount = (
                0.72 + flare_amount * 0.28
            )

            final_color = blend_colors(
                base_color,
                overlay,
                overlay_amount,
            )
        else:
            final_color = base_color

        pixels[index] = final_color

    pixels.show()

Por último el bucle principal, que se ejecuta 50 veces por segundo:

Python
try:
    while True:
        motion_change = read_motion_and_tilt()
        now = time.monotonic()

        if (
            motion_change >= FLARE_TRIGGER
            and now - last_flare_time >= FLARE_COOLDOWN
        ):
            start_traveling_flare()
            last_flare_time = now

        update_embers()
        update_traveling_flare()
        draw_pixels()

        time.sleep(FRAME_DELAY)

Un detalle práctico: este fragmento abre un bloque try: cuyo manejador de excepciones está en el archivo completo del Project Bundle enlazado al final. Si copias solo este trozo, agrega tu propio except o quita el try: y desindenta el bucle.

Armar los cuernos

La electrónica es la mitad rápida; el papel maché es la que toma tiempo (y días de secado). El procedimiento del original funciona bien tal cual: se moldea un cono con papel de aluminio sobre film plástico, se cubre con tiras de papel de diario empapadas en cola de madera diluida, y se deja secar por completo antes de sacar el molde interior. El resultado es una cáscara hueca, liviana y translúcida, que es la clave: si el papel queda muy grueso, los LEDs no se ven.

Una vez seca la cáscara, la tira se enrolla en espiral desde la base hacia la punta y se fija con silicona caliente, con el pixel 0 en la base. el código asume ese orden y toda la animación va de base a punta.

Cable JST con interruptor en línea

Para el cableado interno usa cable flexible de 26 AWG en tres colores (negro para GND, rojo para +, y un tercero para datos): con cables de un color solo, encontrar un falso contacto dentro de un cuerno de papel es una tortura.

Cable flexible de núcleo trenzado para el cableado interno

El cintillo lleva la electrónica: Pico, acelerómetro, cargador y batería quedan en la parte superior, tapados después con flores o el decorado que elijas. El interruptor se suelda en serie con el cable JST de la batería, para poder apagar sin desconectar nada.

Cable USB tipo A a tipo C para programar la placa

Variantes y mejoras

1. Sincronizar los dos cuernos por separado en vez de en espejo. En vez de cablear las tiras en paralelo, encadénalas (DO de la primera al DI de la segunda) y sube PIXEL_COUNT a 42. Con eso cada cuerno pasa a tener sus propios pixeles direccionables y puedes hacer que la llamarada suba por uno y baje por el otro, o que respondan a la inclinación de forma asimétrica: el cuerno del lado hacia el que inclinas se carga más que el otro. Es cambiar el índice en charge_amount_for_pixel.

2. Cambiar la paleta de brasas por una de fuego frío. Los colores están todos en tres funciones (ember_color, flare_color, charge_color) y son tuplas RGB directas. Reemplazando el (255, 24, 3) por (20, 80, 255) y ajustando las otras dos en la misma dirección, los mismos cuernos pasan de infierno a hielo sin tocar una sola línea de la lógica de animación.

3. Agregar sonido con el amplificador del Pico. El proyecto original tiene salida de audio I2S en la placa. Con el Pico puedes conseguir un efecto parecido con un módulo amplificador PAM8403 y un parlante pequeño, disparando un archivo .wav desde CircuitPython en el mismo instante en que se dispara la llamarada. La condición del disparo ya está escrita en el bucle principal: es el mismo if que llama a start_traveling_flare().

Personalización para Chile

Lista completa con lo que sí está en el catálogo de MechatronicStore:

  • Raspberry Pi Pico USB C (SKU N-221). $8.990 CLP
  • Cinta LED WS2812B 60 LED/m, rollo 5 m (SKU G-599). $23.990 CLP
  • Giroscopio y acelerómetro MPU-6050 GY-521 (SKU GO3-4). $4.490 CLP
  • Batería Li Ion 3.7V 600 mAh (SKU GE1-11). $8.990 CLP
  • Módulo cargador TP4056 USB-C (SKU GQ2-12). $1.990 CLP
  • Cable conector JST 2 pines 10 cm (SKU C-209). $990 CLP
  • Mini switch interruptor 3 pines (SKU GF1-14). $290 CLP
  • Cable UL1007 26 AWG por metro (SKU V-121V1, hay otros colores). $500 CLP
  • Cable USB Tipo C a Tipo A 1 m (SKU B-101). $2.190 CLP

Subtotal en tienda: $52.420 CLP. Súmale unos $6.000 en cintillo, cola de madera y papel, que se consiguen en cualquier librería o ferretería.

Dos aclaraciones honestas sobre esta lista. La tira WS2812B se vende en rollo de 5 metros y el proyecto usa menos de un metro: es el ítem caro de la lista, pero te quedan 4 metros para los próximos proyectos. Y si prefieres gastar menos en la placa, la Seeed XIAO RP2040 (SKU GS1-5, $7.790) es el mismo chip en un formato mucho más chico. una ventaja real cuando todo va escondido bajo un cintillo. , con la única diferencia de que hay que revisar la equivalencia de pines antes de cablear.

El cintillo de 5 cm y los materiales de papel maché no están en el catálogo de MechatronicStore; los incluimos en la lista igual para que veas el costo real del proyecto completo.

Recursos

Versión chilena inspirada en el proyecto de Erin St Blaine para Adafruit, adaptada a componentes en stock local en MechatronicStore.