Por qué un protocolo binario (y no JSON)
La opción fácil era JSON. También era la que hacía imposible el target de 30 FPS. Un protocolo binario de seis bytes de overhead resuelve el cuello de botella.
La opción más fácil era enviar JSON. Era rápido de escribir, debuggable con
tail -f, legible por humanos. También era la que hacía físicamente imposible llegar a 30 FPS.
El contexto
La primera versión del host enviaba hex ASCII: una línea
por LED con formato 0xRRGGBB\n. Ocho caracteres + salto
de línea = 9 bytes por LED. Para 299 LEDs: 2 691
bytes por frame (sumando el \n final).
A 230 400 baudios reales — contando 10 bits por byte una vez sumados el bit de start y el de stop —, el canal entrega unos 23 040 bytes por segundo. Un frame, para ir por el cable, quiere ~117 milisegundos. Techo teórico: menos de 9 FPS, antes de contar nada del host, nada del Arduino, nada del CRC, nada de recuperación de errores.
El PRD pide 30 FPS sostenidos a 300+ LEDs. La diferencia entre 9 y 30 no es optimizable: el protocolo no puede.
La decisión
Frame binario minimalista, sin ambición:
Offset Tamaño Campo
0 1 SOF = 0xA5
1 1 VERSION = 0x01
2 1 TYPE (DATA=0x01, INIT=0x02, PING=0x03, ACK=0x04)
3 2 LEN (big-endian, uint16)
5 LEN PAYLOAD
5+LEN 1 CRC8 (cubre VERSION..PAYLOAD-1)
Seis bytes de overhead total por frame, independiente del contenido. Cuatro tipos de frame:
DATA: N × 3 bytes RGB. El pan de cada día.INIT: 4 bytes — pin (uint16) + npixels (uint16). El host configura la tira al arrancar.PING: payload vacío. Health-check.ACK: 1 byte de status. Respuesta del firmware.
El mismo frame de 299 LEDs ahora son 5 + 897 + 1 = 903 bytes. A 500 000 baudios — el nuevo setpoint —, ~50 000 bytes/segundo. Techo teórico: ~55 FPS. Margen real para el target de 30 FPS con gestión de errores, retransmisiones y backpressure incluidos.
Por qué estas decisiones concretas
Por qué SOF = 0xA5. Un patrón de bits 10100101 —
alterna lo bastante para que cualquier byte stuck-low o
stuck-high se note, y es un valor poco probable de aparecer
accidentalmente al inicio de un payload RGB real.
Por qué LEN big-endian. Ninguna razón de eficiencia;
solo convención de wire (“network byte order”). Un firmware
en C lo desempaqueta con dos líneas; el host Python, igual.
Por qué CRC8 y no CRC16 o ninguno. A 30 FPS y bytes
cortos, una probabilidad de 1 / 256 de no detectar un error
es tolerable. El CRC16 dobla el overhead por frame y no
detecta significativamente mejor a este tamaño. Ningún
CRC dejaría pasar errores silenciosos — y con WS281x eso
se traduce en un LED random a mitad de la tira, no en un
crash. Ver errores esporádicos es peor que detectarlos y
poder retransmitir.
Por qué generadores en el decode. El codec del host
expone decode(stream) -> Iterator[Frame]. Lee bytes hasta
que encuentra un SOF anclable; si encuentra cualquier
otro byte antes, lo descarta silenciosamente (resync).
Es el mismo comportamiento que tiene el firmware. Si el
cable tiene ruido o quedan bytes huérfanos entre frames
concatenados, el codec se recupera solo sin prácticamente
retransmisión.
Errores tipados, no booleanos. La jerarquía
DecodeErrortiene cuatro subclases:VersionMismatch,CRCMismatch,LengthError,UnknownFrameType. Importa porque la respuesta del host es distinta para cada una. UnCRCMismatchse retransmite; unVersionMismatchaborta y renegocia (el firmware corre una build antigua, por ejemplo); unLengthErrorno se retransmite — el frame estaba mal formado. DevolverFalseoNoneperdería esta distinción y escondería el bug del host detrás de una retransmisión que nunca funcionaría.
Lo que viene
Tenemos un protocolo que cabe por el cable. Ahora hace
falta un host que sepa generar frames a 30 FPS, hacerlo
mientras la UI web pide cosas, y no congelarse cuando el
camino feliz (tick → encode → write → ack) se
desincroniza una milésima. Próximo post: la arquitectura
Python.