Pintando en el Spectrum (2)

Esta mañana estaba revisando mi código y se me ocurrió una pequeña optimización. JP cc, nn (salto condicional) necesita 10 Tstados, mientras que DJNZ necesita 13 cuando no se cumple la condición, y 8 cuando sí se cumple. Dado que siete veces no se cumple pero una sí se cumple, si podemos sustituir el DJNZ por un JP pe, nn, ahorraremos 19 Tstados en cada fila. Sólo tenemos que cargar BC con 256, que es el número de transferencias que tenemos que hacer entre grupos de atributos. Pero además, dado que cada vez que terminamos una fila de caracteres BC valdrá cero, podemos simplemente incrementar B en uno, que es una operación más rápida que cargar un número, con lo que ahorraremos 3 Tstados más. Así quedaría el código:

    LD SP, tabla_direcciones
    LD HL, buffer
    LD BC, 0 ; mismo valor que si hubiésemos hecho una fila entera
    EXX ; cambiamos al juego de registros alternativo
    LD HL, buffer + 6144 ; apunta a los atributos de color del buffer
    LD DE, 22528 ;  zona de atributos de la pantalla
    LD BC, 768 ; tamaño de los atributos
 loop1:
     EXX ; volvemos al juego original con los datos de píxeles
     INC B ; como BC aquí vale cero, esto es igual que LD BC, 256
           ; pero más rápido
 loop2:
     POP DE
     LDI
     … ; 32 LDIs en total
     LDI
     JP PE, loop2
     EXX
     LDI
     … ; 32 LDIs en total
     LDI
     JP PE, loop1 ; no podemos usar DJNZ porque el salto es
                   ; de más de 128 bytes
     …
 tabla_direcciones:
     DEFW 0x4000, 0x4100, 0x4200, 0x4300, 0x4400, 0x4500, 0x4600, 0x4700
     DEFW 0x4020, 0x4120, 0x4220, 0x4320, 0x4420, 0x4520, 0x4620, 0x4720
     ; completar hasta las 192 líneas

Ahora el bucle interno dura 11 + 512 + 10 = 533 Tstados, aunque en las zonas con contienda serán 536, por lo que necesitaremos entre 4 264 y 4 288 Tstados por cada fila de caracteres. Sumando la parte de los atributos tenemos que serán 4 + 4 + 4 + 512 + 10 = 534 Tstados extra, que cuando haya contienda subirá a 536 Tstados. Y esto para cada una de las 24 filas de la pantalla, lo que nos da entre 115 152 y 115 776 Tstados, lo que significa que en el peor de los casos estamos igual, pero en el mejor ahorramos algo.

¿Pero realmente existe ese «mejor de los casos» si, al escribir en pantalla, siempre tenemos contienda? En realidad esto sólo es verdad a medias: sólo tenemos contienda cuando la ULA está leyendo de la memoria para pintar el paper, pero no cuando está pintando el borde. Teniendo en cuenta que de las 312 líneas de la pantalla, sólo 192 tienen contienda, y las 192 las recorremos dos veces (pues primero el haz va por delante nuestra, pero al llegar al final de la pantalla y volver al principio va por detrás hasta que nos alcanza) tenemos un total de 504 líneas, de las cuales 120 no tienen contienda. Eso significa que el tiempo real será, aproximadamente, 0,762 * tiempo_peor + 0,238 * tiempo mejor. Por tanto, en este caso, tenemos que tardaremos 115 627 Tstados, frente a los 115 764 Tstados del caso anterior. No es mucha diferencia, pero cualquier Tstado que ahorremos es tiempo que podemos emplear luego en generar el siguiente frame. Y teniendo en cuenta que antes de pintarlo tenemos que sincronizarnos con la pantalla, el pasarnos tan solo un Tstado puede hacer que tengamos que esperar al siguiente frame de la pantalla.

Pintando en el Spectrum (1)

Cuando tenía 12 años heredé el Sinclair ZX Spectrum de mi hermano. Era un ordenador que me fascinaba, y con él aprendí a programar, primero en BASIC, y luego directamente en Ensamblador. También aprendí rudimentos de electrónica digital, y gracias a ello construí varios circuitos que le acoplé, como un teclado nuevo, un puerto de E/S de 16 bits, y más.

Es una máquina a la que siempre le tuve mucho cariño, y por eso me lancé hace unos años a escribir mi propio emulador, FBZX, cuando los que había en aquel entonces no me acababan de convencer. Y recientemente, a raíz de varios canales de youtube de «nostalgia de 8 bits», me he puesto un poquito así y he decidido intentar programar algo. Al principio probé a usar el compilador de Z88dk para poder utilizar lenguaje C, hasta que vi la chapuza de código que genera (algo que no es culpa de los desarrolladores, sino de la propia arquitectura del Z80, que no es nada adecuada para C y, sencillamente, es imposible generar mejor código). Ante esto, decidí pasarme a Z80ASM y trabajar desde cero en ensamblador. A fin de cuentas, en una máquina de 8 bits cada bit cuenta, y el poder optimizar cada rutina hasta la última instrucción puede ser la diferencia entre conseguir o no conseguir algo concreto.

Y precisamente una de las cosas en donde la velocidad es crítica es a la hora de pintar en la pantalla, pues, por desgracia, el Spectrum original no tiene ninguna ayuda para esa tarea, si siquiera una doble página (a pesar de que sólo habría requerido añadir un único flip-flop a la ULA). El Spectrum 128K sí tiene dos páginas de vídeo, lo que permite que la ULA muestre una de ellas mientras el código genera el siguiente fotograma en la otra página, y cuando haya terminado, sólo tiene que esperar a que la ULA empiece a pintar un nuevo cuadro para cambiar la página activa, de manera que ahora se mostrará lo que haya en la segunda página y el programa podrá pintar el siguiente fotograma en la primera página.

Cuando se usa el sistema de doble página, las animaciones van fluidas y sin parpadeos ni deformaciones. Por desgracia, los modelos de 48K, al no tener esta capacidad, obligan al programador a sincronizarse con el haz de electrones de la pantalla para evitar que «le pille el rayo» en mitad de un acceso a la pantalla. Hay mucha literatura al respecto, así que no voy a entrar en explicar en qué consiste lo de «competir con el haz«. Sí voy a dar, sin embargo, algunas notas sobre cómo funciona la pantalla en el Spectrum. Para ello, veamos este dibujo de una televisión con la imagen generada por la circuitería del ordenador (la famosa ULA):

Una televisión con la imagen generada por un Spectrum.

En el dibujo vemos una televisión, y en la pantalla podemos ver las dos zonas en las que se divide la imagen generada por la ULA en el Spectrum: el border (en color verde en el dibujo) y el paper, o la zona de trabajo, en color blanco. También vemos esquematizado el recorrido del haz de electrones, de derecha a izquierda y de arriba a abajo, aunque exagerado. El border es una zona en la que sólo podemos definir qué color global queremos, pero no podemos pintar en ella. Sólo en el paper podemos pintar píxeles escribiendo en una zona de memoria, a partir de 0x4000 hasta 0x5800, en donde cada bit se corresponde con un pixel. Esta zona se divide en 256×192 pixels. A mayores, justo a continuación se encuentra la zona que almacena los atributos de color, que mide 768 bytes. Esta zona ocupa un byte para cada grupo de 8×8 píxels, y especifica qué color se usará para cuando el bit asociado a cada pixel está a 0 o a 1.Para más detalles, recomiendo leer la información de World of Spectrum sobre la memoria de vídeo. Edito: o bien la entrada número 4 de esta misma serie, donde entro en más profundidad en cómo es la distribución de la pantalla en el Spectrum. Siento no haber hecho las cosas en orden.

La ULA refresca la pantalla a una tasa de 50 veces por segundo. Además, para simplificar la circuitería, no utiliza entrelazado, sino que siempre pinta únicamente las líneas impares. Y dado que el reloj de la CPU va a 3,5 MHz, eso significa que desde que comienza a pintar la imagen, en la esquina superior izquierda del borde, hasta que ha terminado y empieza a pintar la siguiente, tenemos, en teoría, 3 500 000 / 50 = 70 000 ciclos de reloj o Tstados. Y como tenemos 312,5 líneas (recordemos que sólo utilizamos medio campo, por lo que es la mitad de 625), cada línea dura 224 Tstados. En la práctica no podemos tener media línea, por lo que, en realidad, cada frame dura 69 888 Tstados y tenemos un total de 312 líneas. Además, sabemos que cada vez que se empieza a pintar un frame, la ULA genera una interrupción, la cual podemos utilizar para sincronizarnos con la generación de la imagen. Por último, hay 64 líneas de border antes de que empiece a pintarse el paper.

Con todo esto ya podemos hacer un primer cálculo, que nos dice que si queremos estar seguros de que hemos pintado todo en pantalla antes de que el haz llegue al paper, tenemos que hacerlo en menos de 64 * 224 = 14 336 Tstados. Por desgracia esto es muy poco tiempo.

Una solución consiste en esperar a que la ULA haya terminado de pintar el paper y, entonces, pintar lo que necesitemos. En este caso tendremos 69 888 – (224 * 192) = 26 880 Tstados, que es el tiempo que tarda en pintar la parte inferior y la superior del borde. Ya es un poco más, pero todavía no llega para demasiado.

La solución que usan muchos juegos consiste en implementar una doble página por software. Para ello, pintan toda la imagen en una zona diferente de memoria, y cuando está lista, la copian de golpe en la memoria de pantalla. De esta manera la imagen que aparece siempre es definitiva y no hay parpadeos. El inconveniente es que consume casi siete Kbytes extra.

El problema es que, como de costumbre, la cosa no es tan sencilla. Veamos por qué. La primera idea, la más naive, sería utilizar la instrucción LDIR del Z80. Esta instrucción permite copiar un bloque de memoria a otra posición, y recibe tres parámetros: la dirección inicial del bloque (en el registro HL), la dirección de destino (en el registro DE), y el tamaño del bloque (en el registro BC), y hace todo el trabajo por nosotros: lee el byte contenido en la dirección de memoria apuntada por HL, lo escribe en la dirección de memoria apuntada por DE, incrementa en uno ambos registros, decrementa en uno el registro BC, y repite la operación hasta que este último valga cero. ¡Una bicoca! Así que lo único que tendríamos que hacer es esperar a la interrupción, cargar los valores en los registros, y ejecutar LDIR. ¿O no?

El problema es que cada iteración de LDIR consume 21 Tstados. Eso significa que una pantalla completa, que ocupa 6 912 bytes, tardará 145 152 Tstados en copiarse… ¡que es más del doble de lo que se tarda en generarse! Eso significa que el haz nos alcanzará como mínimo dos veces en cada volcado. Y sí, digo «como mínimo», porque por cuestiones de diseño, la pantalla no se almacena de manera secuencial en memoria, sino que primero viene la fila 0 de pixels, ocupando 32 bytes, luego la fila 8, luego la 16, 24, 32, 40, 48 y la 56, momento en que vuelve atrás hasta la fila 1, luego la 9, la 17… Eso hace que podamos cruzar el haz varias veces, y complica aún más la gestión de la pantalla.

Por si fuera poco, hay un segundo problema, y es que la ULA interfiere en el acceso a la RAM cuando está generando la imagen. Esto es debido a que en chips de RAM normales no es posible que dos sistemas lean o escriban a la vez, sino que se tienen que turnar. La ULA necesita leer dos bytes (uno con los pixels, y otro con los atributos de color) cada cuatro Tstados mientras está pintando el paper, y sólo deja la memoria libre cuando está pintando el borde. Para conseguir esto, lo que hace es monitorizar constantemente a la CPU, y si intenta acceder a la zona de vídeo mientras la ULA está pintando el paper, le detiene el reloj a la CPU hasta que termine.

Por suerte, gracias a un ingenioso diseño, la ULA sólo necesita tres Tstados para leer esos dos bytes, así que lo que hace es agrupar dos lecturas seguidas y dejar los dos Tstados restantes para la CPU. Esto significa que, si queremos que la escritura en la zona de vídeo no se vea penalizada por la ULA, tenemos que hacer accesos con un periodo que sea múltiplo de ocho Tstados. De esta manera, el primer acceso se verá retrasado entre uno y seis Tstados según donde caiga, pero el resto coincidirán exactamente en el siguiente hueco. Y este es otro de los problemas de LDIR: como cada ciclo dura 21 Tstados, que no es múltiplo de ocho, significa que, en la práctica, será como si la instrucción durase 24 Tstados (la primera hará la escritura en el primer hueco libre; la segunda lo hará en el segundo hueco, tres periodos después, con lo que ambas durarán 21 Tstados; la tercera, en cambio, se retrasará seis Tstados, la cuarta no sufrirá retraso, la quinta se retrasará seis Tstados… y así sucesivamente).

Sin embargo, si vemos el juego de instrucciones, comprobaremos que existe también una instrucción similar, LDI, que hace lo mismo excepto repetir la operación, y sólo consume 16 Tstados, que, además, es múltiplo de 8 y, por tanto, no sufriría penalización por parte de la ULA. ¿Qué pasaría si pusiésemos una laaaaaarga ristra de ellas, una detrás de otra? Pues que necesitaríamos 6 912 * 16 = 110 592 Tstados. Aún es más que el tiempo que se necesita para refrescar una pantalla, pero aún nos queda un as en la manga. ¿Qué ocurriría si esperamos a que el haz llegue hasta el principio del paper, y justo entonces empezamos a pintar? En ese caso, el haz iría por delante nuestra, y nunca lo adelantaríamos porque ya hemos visto que somos más lentos que él, y eso significa que cuando el haz haya pintado toda la pantalla y vuelto al punto de partida, ya llevaremos más de la mitad de la imagen pintada y, además, tendremos aún casi otro frame de tiempo antes de que nos alcance.

Veamos exactamente de cuanto tiempo disponemos. Tenemos los 69 888 Tstados que tarda en recorrer un frame, y ahora tenemos que sumarle el tiempo que tarda en llegar hasta el borde inferior derecho del paper. Esto será 224 Tstados_por_linea * 191 lineas_completas + 128 Tstados_de_la_ultima_linea = 42 912 Tstados. Esto significa que si utilizamos este truco, tendremos disponibles un total de 112 800 Tstados para copiar la pantalla antes de que nos alcance el haz, que es más de lo que tarda la ristra de LDIs. ¡Buena cosa!

Por desgracia, esta idea tiene dos problemas:

  • Para que funcione, tenemos que escribir los datos de manera lineal en la pantalla, de manera que nos mantengamos siempre por detrás del haz. Pero si usamos LDI directamente, se hará de manera escalonada por cómo está organizada la pantalla del Spectrum, y adelantaremos y retrasaremos al haz constantemente.
  • Cada instrucción LDI ocupa dos bytes, lo que significa que necesitaríamos una ristra de instrucciones que ocuparía el doble que el tamaño del bloque a copiar, lo que supone un desperdicio exagerado de memoria.

Así, dado que la única secuencia consecutiva que tenemos son los 32 bytes de cada línea, la solución es utilizar una secuencia de 32 instrucciones LDI, y modificar entre medias los registros para ir línea a línea. El problema es que cómo hacerlo con el mínimo de instrucciones posible, pues de nada sirve si perdemos por un lado lo que ahorramos por otro.

La primera solución consiste en tener una tabla con las direcciones de cada scanline, de manera que sólo tenemos que coger la dirección actual, ejecutar 32 instrucciones LDI, y repetir el bucle hasta que el flag de desbordamiento (overflow) se active después de la última instrucción LDI. El problema es que LDI utiliza los tres pares de registros principales, con lo que no nos queda nada para mantener un puntero a la lista de direcciones…

¿O sí que lo tenemos? Porque podemos simplemente cargar el puntero de pila SP con la dirección de la tabla, y leer los valores directamente con POP DE. Esta instrucción tiene la ventaja de que con sólo 11 Tstados nos carga un valor de 16 bits e incrementa el puntero de la tabla.

El resultado sería este código:

LD SP, tabla_direcciones
LD HL, buffer
LD BC, 6144 ; tamaño del buffer. De momento prescindimos del color
loop:
POP DE
LDI
LDI
… ; 32 instrucciones LDI en total
LDI
JP PE, loop

tabla_direcciones:
DEFW 0x4000, 0x4100, 0x4200, 0x4300, 0x4400, 0x4500, 0x4600, 0x4700
DEFW 0x4020, 0x4120, 0x4220, 0x4320, 0x4420, 0x4520, 0x4620, 0x4720
…. ; completar hasta las 192 líneas

Este código es muy rápido: dado que la instrucción JP consume 10 Tstados, tenemos que copiar cada fila consume un total de 533 Tstados; pero como tenemos que redondear a un múltiplo de 8 para tener en cuenta la contienda de memoria, se nos quedan en 536 Tstados; y como una pantalla completa son 192 líneas, al final gastamos entre 102 336 y 102 912 Tstados (no olvidemos que sólo hay contienda mientras se pinta el paper, nunca mientras se pinta el border, por lo que el valor final estará entre ambos), lo que está muy por debajo del límite de 112 800 Tstados. ¡Buena cosa! Por desgracia, nos falta por copiar los atributos de color, y el problema es que tenemos que copiar una fila de atributos por cada ocho de píxels, pues tenemos que mantenernos por detrás del haz catódico; no podemos copiar primero todos los píxels y luego todos los atributos. El problema es que ya estamos usando absolutamente todos los registros con la instrucción LDI, y ni siquiera tenemos la pila disponible para almacenar los valores entre uno y otro grupo.

¡¡¡Pero tenemos el juego de registros alternativo!!! El Z80 tiene su conjunto principal de registros, AF, BC, DE y HL, pero también tiene un segundo juego, AF’, BC’, DE’ y HL’, que podemos intercambiar con sólo dos instrucciones: EX AF, AF’ (que intercambia los valores de AF y AF’) y EXX (que intercambia los valores de BC, DE y HL con los de sus homólogos). Así que, dado que los atributos de color sí siguen un formato normal, podemos copiar ocho filas primero usando la tabla, cambiar al juego alternativo de registros, copiar una fila de atributos de color, volver a cambiar el juego, copiar otras ocho filas de pixeles, etc. El código quedaría así:

LD SP, tabla_direcciones
LD HL, buffer
EXX ; cambiamos al juego de registros alternativo
LD HL, buffer + 6144 ; apunta a los atributos de color del buffer
LD DE, 22528 ; zona de atributos de la pantalla
LD BC, 768 ; tamaño de los atributos
loop1:
EXX ; volvemos al juego original con los datos de píxeles
LD B, 9 ; las ocho filas necesitan 256 LDIs. Cada uno resta
; uno a BC; luego al final de ellos, C valdrá lo mismo
; pero B se habrá decrementado en una unidad. Así
; que tenemos que tenerlo en cuenta para DJNZ
loop2:
POP DE
LDI
… ; 32 LDIs en total
LDI
DJNZ loop2
EXX
LDI
… ; 32 LDIs en total
LDI
JP PE, loop1 ; no podemos usar DJNZ porque el salto es
; de más de 128 bytes

tabla_direcciones:
DEFW 0x4000, 0x4100, 0x4200, 0x4300, 0x4400, 0x4500, 0x4600, 0x4700
DEFW 0x4020, 0x4120, 0x4220, 0x4320, 0x4420, 0x4520, 0x4620, 0x4720
; completar hasta las 192 líneas

El bucle interno consume un total de 512 + 11 + 13 = 536 Tstados cuando B es distinto de cero (que, además, es múltiplo de 8), y 512 + 11 + 8 = 531 Tstados cuando B es cero. Por tanto, para ocho filas tenemos un total de 536 * 7 + 531 = 4 283 Tstados. Pero a esto hay que sumar la inicialización previa y la copia de los atributos. Antes tenemos, del EXX y el LD B, 9, un total de 4 + 7 = 13 Tstados, y después 4 + 512 + 10 = 526 Tstados; luego cada fila completa de 8 píxels de altura y con atributos necesita un total de 4 283 + 13 + 526 = 4 822 Tstados. Pero para que sea múltiplo de 8 hay que subir a 4 824 Tstados. Y como tenemos 24 filas, el total estará entre 115 728 y 115 776 Tstados.

Vaya, ahora nos hemos pasado. Una pena, porque eso significa que no podemos animar algo a pantalla completa sin que haya artifacts. Pero si copiamos sólo 23 filas sí nos da tiempo, pues ahí no necesitaríamos más de 110 952 Tstados. Por tanto, una solución consiste en obviar la última fila, lo cual no tiene por qué ser un problema, pues normalmente se suele dejar la parte baja de la pantalla para marcadores y otros elementos relativamente estáticos. Además, tenemos la ventaja de que el buffer de trabajo está en un formato secuencial, lo que simplifica pintar en él.

Esta rutina ya sería utilizable, pero tiene el inconveniente de necesitar una tabla de 384 bytes para las direcciones, que es mucho mayor que los 157 bytes de la rutina en sí. Sin embargo, si renunciamos a las dos últimas filas en lugar de sólo a la última, podemos reducir muchísimo la memoria consumida. Pero eso será en el próximo artículo.

A ritmo de conga (16)

Hace unos días me encontré con un problema raro: mi aspiradora no respondía a mis órdenes en la app. Pero lo mas raro era el estado de los botones: estaba cargando, pero el botón de «volver a casa» estaba activo, y el de «empezar a limpiar» estaba desactivado.

Un vistazo rápido a los logs me permitió descubrir que el estado actual de la aspiradora era «10», y antes había pasado por el «9» y por el «7». Esto era muy raro, pues los únicos estados que conocía hasta ahora iban desde el «1» (limpiando), hasta el «6» (en la base, batería cargada), tal y como comenté en la entrada «A ritmo de conga (6)».

Obviamente esto era muy raro, así que empecé a investigar revisando los logs, y descubrí qué ya me había ocurrido lo mismo en dos ocasiones más. Las preguntas obvias eran: ¿por qué? y ¿Qué significaban esos nuevos estados?

Entonces me fijé en un detalle raro: antes de pasar al modo «9», la aspiradora estaba limpiando (modo «1»), pero la batería estaba muy baja. De pronto se puso en modo «9», y unos segundos después pasó a modo «10», y entonces el nivel de la batería, de pronto, empezó a subir. Y cuando llegó al 100%, volvió a modo «1», o limpiar.

La respuesta era obvia: cuando la aspiradora está limpiando y ve que la batería está demasiado baja como para terminar el trabajo, ella sola se vuelve a la base (modo «9») para cargarla de nuevo (modo «10») y continuar la operación donde la dejó. El «7» todavía no se qué puede ser. A lo mejor tiene que ver con un par de veces que se me quedó atascada por culpa de un reflejo del sol (no se por qué, pero si da el sol en el parqué, el sensor antiescaleras se lía), pero tengo que hacer más pruebas para confirmarlo.

Así, la lista de estados posibles pasa a ser:

  • 1: limpiando
  • 2: detenida
  • 4: regresando a la base
  • 5: en la base, cargando la batería
  • 6: en la base, batería cargada
  • 7: ??????
  • 9: regresando a la base por batería baja
  • 10: en la base, cargando batería, para continuar un trabajo previo interrumpido por batería baja

A ritmo de conga (15)

Hoy hice algunos cambios extra en el repositorio de OpenDoñita. Para empezar, dado que han actualizado OSMC y ya trae Python 3.7, ahora ya es posible instalar mi servidor directamente en una Raspberry Pi con él. Así lo hice, así que aproveché para añadir un nuevo instalador que sea más sencillo. Ahora, al ejecutar install.sh, se bajarán los paquetes necesarios (tanto de python como del sistema operativo), se instalará todo en el sitio correspondiente, y se lanzarán los servicios necesarios.

Por otro lado, he escrito un nuevo programa para emparejar la aspiradora. Tiene esta pinta:

Gracias a ella, ahora es un poco más sencillo realizar el emparejado desde cualquier ordenador, con la única condición de que éste tenga WiFi.

Crust y protothreads

Hace tiempo había descubierto un curioso mecanismo denominado protothreads. Se trata de una manera de añadir capacidades asíncronas a C. El truco es bastante curioso, pues se basa en una característica poco conocida de C, que consiste en que un switch puede saltar a la mitad de un bucle, ya sea for, while o do…while. Esto es, este código es legal:

switch (a) {
default:
    while(1) {
        do something
        return;
case XXXX:
        do more things
    }
}

Esto funciona perfectamente en C, y además es perfectamente legal, gracias a la definición explícita de cómo funcionan los bucles en este lenguaje.

La idea detrás de esto es generar a mayores una serie de macros y definiciones que permitan embellecer el código, y hacer que se parezca más a programación asíncrona.

La cuestión es que llevo unas semanas trabajando en un proyectillo en C que utiliza eventos y programación asíncrona, y para simplificar y mejorar el código decidí implementar protothreads. Por supuesto, también utilizaba mi viejo analizador estático crust para analizar el código y detectar errores, y justo aquí me encontré con el problema: no era capaz de analizar correctamente estas estructuras.

Así que aquí tenía dos opciones: o pasar de crust, o actualizarlo para que lo soporte. Y obviamente me decidí por lo segundo.

A ritmo de conga (14)

Esta creo que por fin será la última entrada en una serie que se ha prolongado muchísimo más de lo que esperaba. En esta hablaré de los últimos comandos que quedan por analizar.

En primer lugar está el comando 143. Este comando es el que se emite cuando se pulsa una especie de diana que hay en la parte superior derecha de la app oficial, y hace que el robot emita un pitido. En el manual no dice para qué sirve, por lo que lo único que se me ocurre es para localizarlo cuando no sabes donde anda. Sin embargo lo veo poco útil, pues si está en marcha lo escucharás perfectamente, y si está en pausa, al cabo de un minuto más o menos se apagará y dejará de responder a los comandos enviados por WiFi.

Por otro lado, ya he descubierto para qué sirve el comando 400, y no es para indicar que se ha abierto la app. Resulta que, al contrario de lo que pensaba, es la propia aspiradora la que recuerda las tareas cuando la programamos para que aspire tal día a la semana a tal hora. Así, si por ejemplo tenemos dos tareas programadas, una para que limpie de lunes a viernes a las 19:00 horas, y otra para que limpie sábados y domingos a las 16:30 horas, cuando emitimos el comando 400 nos devolverá esto:

{
  "version":"1.0",
  "control":{
    "targetId":"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
    "targetType":"3",
    "broadcast":"0"
  },
  "value":{
    "transitCmd":"401",
    "maxRecord":"15",
    "result":"0",
    "orders":[
      {
        "sign":"1",
        "orderIds":"1,2,3,4,5",
        "valid":"1",
        "hour":"19",
        "minute":"0",
        "mode":"11",
        "fan":"1",
        "waterTank":"255"
      },{
        "sign":"2",
        "orderIds":"0,6",
        "valid":"1",
        "hour":"16",
        "minute":"30",
        "mode":"11",
        "fan":"2",
        "waterTank":"255"
      }
    ]
  }
}

En orders es donde viene toda la información clave: es una lista con tantos diccionarios como programaciones haya. En ellos, el campo sign es el identificador de cada programa. En orderIds vienen los días de la semana en los que se activará, siendo 0 el domingo, 1 el lunes, etc. El campo valid vale 1 si el programa está activo, y 0 si está desactivado. Los campos hour y minute especifican, como cabe suponer, la hora a la que se quiere que comience. Por último, los campos mode, fan y watertank indican el modo de limpieza, la potencia del ventilador y el modo de fregado que se quieren utilizar para ese programa concreto.

El siguiente comando importante es el 402, que permite añadir un nuevo programa a la lista o modificar uno ya existente. El formato es el siguiente:

{
  "cmd":0,
  "control":{
    "authCode":"xxxxxx",
    "deviceIp":"192.168.3.56",
    "devicePort":"8888",
    "targetId":"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
    "targetType":"3"
  },
  "seq":0,
  "value": {
    "orders":[
      {
        "fan":"3",
        "hour":"19",
        "minute":"30",
        "mode":"11",
        "orderIds":"2,0,6,5,4,3,1",
        "sign":"1",
        "valid":"1",
        "waterTank":"255"
      }
    ],
    "transitCmd":"402"
  }
}\n

Vemos que en el campo orders van los valores del programa que queremos añadir o modificar (lo que se sabe gracias al campo sign.

Por último, el comando 404 permite borrar un programa. Su formato es el siguiente:

{
  "cmd":0,
  "control":{
    "authCode":"xxxxxx",
    "deviceIp":"192.168.3.56",
    "devicePort":"8888",
    "targetId":"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
    "targetType":"3"
  },
  "seq":0,
  "value":{
    "orderIds":"3,6",
    "signs":"2",
    "transitCmd":"404"
  }
}\n

El formato es aún más sencillo: junto con el comando y el campo signs, que indica qué programa se quiere borrar, se incluye el campo orderIds con los días del programa a borrar. Este campo tiene que coincidir con lo que contenga en ese momento el campo del mismo nombre del programa a borrar. No he probado qué ocurre si contiene menos o más valores.

Un ultimo detalle curioso es que hace un par de días actualicé el firmware de la WiFi, y curiosamente el contenido del tercer entero de las cabeceras enviadas por el servidor cambió de 0x01090000 a 0x01F20000. Lo mismo para el PONG de respuesta a un PING, que pasó de 0x01080001 a 0x01F10001. Sin embargo, si utilizamos los valores viejos todo sigue funcionando exactamente igual, lo que es curioso.

A ritmo de conga (13)

Una funcionalidad que echaba en falta es el control manual: poder controlar el robot para mandarlo de un lado a otro con el teléfono, en lugar de tener que cogerlo físicamente.

El problema es que, tras analizar el protocolo de control manual a partir de las capturas que había hecho, me encontré con que era ligeramente diferente del que ya estaba utilizando. Para empezar, los comandos no iban a través del servidor, sino que se enviaban directamente desde el móvil a la aspiradora (por fin tenía sentido el puerto 8888). Por otro lado, los «valores misteriosos» no seguían el mismo patrón. Para empezar, es la tablet quien envía los PINGs, y siempre con el mismo número de secuencia: 1a 27 00 00. Por otro lado, estas son varias cabeceras de comandos enviados desde la tablet a la aspiradora:

d1 00 00 00 | fa 00 c8 00 | 00 00 29 27 | 28 27 00 00 | 00 00 00 00
d1 00 00 00 | fa 00 c8 00 | 00 00 2c 27 | 2b 27 00 00 | 00 00 00 00
d1 00 00 00 | fa 00 c8 00 | 00 00 2f 27 | 2e 27 00 00 | 00 00 00 00
d1 00 00 00 | fa 00 c8 00 | 00 00 32 27 | 31 27 00 00 | 00 00 00 00
db 00 00 00 | fa 00 c8 00 | 00 00 35 27 | 34 27 00 00 | 00 00 00 00

Vemos que el primer campo sigue siendo el tamaño, el cuarto sigue siendo un número de secuencia, y el segundo y el cuarto tienen los mismos valores que en el protocolo con el servidor; pero el tercer campo es diferente; de hecho es el valor del número de secuencia pero con los bytes invertidos y sumándole uno al tercer byte.

Esto ya plantea algunas dudas; por ejemplo ¿qué pasa si el número de secuencia es mayor de 0xFFFF? ¿Está prohibido tal vez? Si está permitido ¿el campo tercero será el cuarto más 256 y con los bytes en orden inverso? De hecho ¿realmente el número de secuencia es de cuatro bytes también en el protocolo original, o puede que sean sólo dos bytes?

De hecho, para probar esto último decidí ver hasta qué valor devolvía la aspiradora, y tras varias pruebas me encontré con que 10.000 es el número de secuencia más grande que envía en el protocolo original, tras el cual vuelve al 1. Pero el servidor sí envía números más grandes de 10.000.

Aparte de este problema, está la cuestión de que en el código tendría que añadir un nuevo socket y gestionarlo… no es difícil, pero sí un rollo. Sin embargo… ¿Qué pasaría si se pudiese controlar desde la conexión original? ¿Puede el servidor enviar comandos de control manual?

La pregunta es legítima, pues cabe suponer que el control manual utiliza la conexión directa para reducir la latencia (a fin de cuentas, es un control interactivo), y además, si estamos en el bar no tiene sentido querer controlar manualmente una aspiradora que no podemos ver. Pero aún así hay casos en los que puede ser necesario, por ejemplo que tengamos varias WiFis en nuestra casa y que el teléfono esté conectada a una y la aspiradora a otra. Así que decidí probar justo eso y… ¡¡¡Funcionó!!! ¡¡¡Es posible enviar comandos de control manual a través del socket del servidor!!! Eso simplifica la tarea enormemente.

Ahora toca analizar el formato. Veamos un primer caso: ordenamos al aspirador ponerse a girar alrededor de sí mismo hacia la derecha durante tres segundos (las respuestas de la aspiradora son, simplemente, el estado actual, así que he borrado casi todo el contenido y lo he reemplazado por unos puntos suspensivos para no alargar demasiado el bloque):

1315.019054889679
s->a e1 00 00 00 | fa 00 c8 00 | 00 00 f2 01 | 2d 27 00 00 | 00 00 00 00
{
  "cmd":0,
  "control":{
    "authCode":"xxxxxx",
    "deviceIp":"192.168.3.56",
    "devicePort":"8888",
    "targetId":"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
    "targetType":"3"
  },
  "seq":0,
  "value":{
    "direction":"4",
    "transitCmd":"108"
  }
}\n

1315.3824479579926
a->s f6 01 00 00 | fa 00 00 00 | 01 00 00 00 | 2d 27 00 00 | 00 00 00 00
[...]



1317.020054889679
s->a e1 00 00 00 | fa 00 c8 00 | 00 00 f2 03 | 2d 27 00 00 | 00 00 00 00
{
  "cmd":0,
  "control":{
    "authCode":"xxxxxx",
    "deviceIp":"192.168.3.56",
    "devicePort":"8888",
    "targetId":"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
    "targetType":"3"
  },
  "seq":0,
  "value":{
    "direction":"4",
    "transitCmd":"108"
  }
}\n

1317.3924479579926
a->s f6 01 00 00 | fa 00 00 00 | 01 00 00 00 | 2d 27 00 00 | 00 00 00 00
[...]



1318.9394080638885
s->a eb 00 00 00 | fa 00 c8 00 | 00 00 f2 01 | 2f 27 00 00 | 00 00 00 00
{
  "cmd":0,
  "control":{
    "authCode":"xxxxxx",
    "deviceIp":"192.168.3.56",
    "devicePort":"8888",
    "targetId":"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
    "targetType":"3"
  },
  "seq":0,
  "value":{
    "direction":"5",
    "tag":"4",
    "transitCmd":"108"
  }
}\n

1319.4073688983917
a->s f6 01 00 00 | fa 00 00 00 | 01 00 00 00 | 2f 27 00 00 | 00 00 00 00
[...]

Y ya vemos cómo va: el comando es el 108 , y luego hay un campo direction que vale 4. ¿Será el comando 108 para girar a la derecha, y habrá otros para el resto de direcciones, o será un único comando para todo y el campo direction indica cual de los cuatro posibles movimientos se desea? Además, al cabo de dos segundos vemos que se vuelve a repetir el comando. ¿Por qué?

Finalmente, al cabo de tres segundos se emite el comando con la dirección 5, por lo que cabe suponer que eso significa detente. ¿Pero qué significa el campo tag?

Para resolver todas estas preguntas, veamos todos los comandos emitidos para los cuatro movimientos posibles (adelante, atrás, izquierda y derecha):

adelante:
  "value":{
    "direction":"1",
    "transitCmd":"108"
  }

  "value":{
    "direction":"5",
    "tag": "1",
    "transitCmd":"108"
  }

------------------------
atrás:
  "value":{
    "direction":"2",
    "transitCmd":"108"
  }

  "value":{
    "direction":"5",
    "tag": "2",
    "transitCmd":"108"
  }

------------------------
izquierda:
  "value":{
    "direction":"3",
    "transitCmd":"108"
  }

  "value":{
    "direction":"5",
    "tag": "3",
    "transitCmd":"108"
  }

------------------------
derecha:
  "value":{
    "direction":"4",
    "transitCmd":"108"
  }

  "value":{
    "direction":"5",
    "tag": "4",
    "transitCmd":"108"
  }

¡Ajá! Ahora tiene sentido: se utiliza un único comando, el 108, para los movimientos manuales, con 1, 2, 3 y 4 para moverse adelante, atrás, izquierda y derecha respectivamente. Cuando se quiere parar se emite el mismo comando pero con la dirección 5, y el campo tag especifica qué movimiento es el que se cancela (probablemente por si los comandos llegan fuera de orden).

Además, si probamos con movimientos que duren más tiempo se ve que el servidor vuelve a enviar el comando de movimiento cada dos segundos. Todo apunta a que es una manera de asegurar que el servidor «sigue ahí», y que si pasa mucho tiempo sin recibir un comando de refresco, la aspiradora dejará de ejecutar el último comando. Esto tiene sentido: si se pierde la conexión es importante que el robot no se quede ejecutando una orden de movimiento manual…

Y con esto ya tenemos todo lo necesario para implementar el control manual, y así de bonito se ve en la app:

Como se ve, hay un nuevo icono que permite alternar entre modo mapa y modo control manual, y pulsando las flechas el robot se moverá. Eso sí, por desgracia la aspiradora no puede estar en la base para que este modo funcione, parece una limitación del propio firmware del robot, no de la app original.

Parte 14

A ritmo de conga (12)

Hasta ahora he estado utilizando la web app para controlar mi aspiradora robot tanto desde el ordenador como desde el móvil. El problema es que es bastante pesado, en el móvil, tener que:

  • Abrir el navegador
  • Abrir una pestaña en blanco
  • Escribir la IP del servidor número a número

Así que decidí que tenía que hacer una app para Android. Obviamente no me iba a matar repitiendo todo el trabajo que ya había hecho en JavaScript, así que la solución obvia era hacer una aplicación que simplemente tuviese un WebView (que no es más que un widget con un navegador web completo) y que cargase automáticamente la web app de OpenDoñita automáticamente cada vez que se abriese. Así cualquier cambio que hiciese a la web app aparecería automáticamente también en la app de Android.

Sin embargo, antes que eso tenía que resolver un problema nada trivial: ¿cómo saber la IP del servidor de OpenDoñita? Sí, en mi casa se cual es, pero obviamente si quiero que otras personas puedan usarlo, no es plan de tener que poner la IP «a mano». Además ¿y si el DHCP hace de las suyas en un reinicio y cambia la IP?

La solución vino de la mano de uPnP. Se trata de un estándar para que dispositivos multimedia puedan anunciarse en una red doméstica, y que otros dispositivos puedan identificarlos y comunicarse con ellos de manera estandarizada. También sirve (y será lo que les suene a muchos) para poder abrir puertos externos en el router cuando usamos NAT.

El protocolo uPnP es, en esencia, relativamente sencillo: se utiliza la dirección multicast 239.255.255.250 y el puerto 1900 para enviar y recibir paquetes UDP. Así, si un dispositivo quiere anunciar que cumple con el estándar uPnP, emitirá una serie de paquetes NOTIFY a dicha dirección y puerto, y aquellos dispositivos interesados estarán suscritos a ella para detectar dichos mensajes. Otra manera es que un dispositivo envíe a dicha dirección y puerto un paquete M-SEARCH, y los dispositivos responderán cada uno indicando sus capacidades y demás.

Por supuesto, cuando entramos en detalles nos encontramos con que el protocolo es mucho más rico y complejo de lo que parece. Pero afortunadamente existe el módulo de python iot-upnp, que permite de manera sencilla configurar un dispositivo como servidor uPnP. Precisamente ella ha sido el motivo de que convirtiese el código a asyncio. Básicamente basta con asignar un UUID y un par de cosas más a un diccionario, y nuestro programa ya es un servidor uPnP y responde a los anuncios. Este código está añadido en la última versión del servidor OpenDoñita.

La segunda parte es conseguir que la app de Android pida a los dispositivos uPnP que se identifiquen. En este caso no me compliqué y me limité a crear un socket UDP y enviar directamente una petición uPnP de tipo M-SEARCH, que es un paquete con estos datos:

M-SEARCH * HTTP/1.1\r\n
HOST: 239.255.255.250:1900\r\n
MAN: \"ssdp:discover\"\r\n
MX: 2\r\n
ST: upnp:rootdevice\r\n\r\n

Con eso recibiré un paquete UDP por cada dispositivo raíz uPnP directamente al mismo socket desde el que envié el paquete. Y como sólo quiero conocer la IP y nada más, lo único que necesito hacer es esperar a que aparezca uno que contenga el UUID que envía mi web app, y ese será.

Por supuesto, las cosas no son tan sencillas, pues la clase de sockets, DatagramSocket, es síncrona, lo que significa que no la podemos utilizar desde el bucle principal de Android sino que necesitamos crear un thread. Para ello utilicé una clase que extiende AsyncTask. Aunque es un método obsoleto, lo es a partir de la API 30, la cual fue lanzada ayer como quien dice (pertenece a Android 11), por lo que prefiero utilizarla y garantizar que mi código va a funcionar en móviles antiguos. Ahora simplemente implemento el método doInBackground() y dentro hago un bucle en el que envío el paquete anterior, me pongo a esperar respuestas con un timeout de 2 segundos (que coincide con el valor de MX de mi petición), y cuando salte éste, si no he conseguido la IP, repito el proceso. Pero si en alguno de los paquetes venía el UUID correcto, salgo del bucle y retorno del método. Es entonces cuando se ejecutará el método onPostExecute() recibiendo como parámetro el valor que devolví en doInBackground(). Lo interesante es que mientras que ésta última se ejecutaba en otro thread, onPostExecute() se ejecuta en el mismo thread desde el que se creó el objeto, o sea, desde el bucle principal en mi caso, con lo que ahí podré llamar al método loadUrl() del WebView para que cargue la página.

La otra cuestión importante es poder capturar el botón de Atras de Android para poder ocultar la pantalla de configuración si se pulsa, pero salir de la app si se pulsa desde la pantalla principal. Para ello sobreescribo en la actividad principal el método onBackPressed(), que es el que se llama cuando el usuario pulsa el botón, y dentro de él utilizo evaluateJavascript() para llamar a la función back_android() de la web app. Esta función hará lo que tenga que hacer y devolverá el valor 0 si no se debe hacer nada, o 1 si se debe salir de la aplicación.

Y el resto no es más que el típico código para gestionar el ciclo de vida de una aplicación de Android.

El código está disponible en mi repositorio gitlab de OpenDoñita para Android.

Parte 13

A ritmo de Conga (11)

Estos días de vacas estuve aún haciendo alguna que otra cosita con la aspiradora. Para empezar, he portado el código a asyncio, además de permitir rotar el mapa y limpiar un poco el código JavaScript de la app web.

Sin embargo, por accidente me encontré con un serio problema de mi aspiradora. Todo empezó un día que, al mandarla limpiar, se paró a los diez minutos sin venir a cuento. Le daba a limpiar de nuevo, se movía un poco y volvía a detenerse… muy raro. Entonces me di cuenta de que la batería estaba bajo mínimos, por debajo del 30%. Eso era muy raro, pues llevaba más de un día cargando, pero supuse que, a lo mejor, no había enganchado bien, y no le di mayor importancia. La puse a cargar, y cuando estuvo al 100% la puse a limpiar sin más problema.

Sin embargo, un par de días después me di cuenta de que la aspiradora estaba constantemente conmutando de «cargando» a «cargada» cada diez-quince segundos pero la carga de la batería estaba muy baja… bastante raro. Probé a quitarla de la base y a ponerla de nuevo y el problema pareció corregirse, pues ahora sí se puso a cargar y el nivel de la batería empezó a subir. Sin embargo me ocurrió lo mismo unos días después, así que decidí echar un vistazo a los logs de mi webapp, en concreto al nivel de la batería, y me encontré con algo muy raro:

Ahí arriba vemos el histórico de la carga de la batería. En azul está cuando la aspiradora está en la base cargándose, en verde cuando está en la base ya cargada, y en rojo cuando está limpiando. Y esos bloques de carga-cargada son bastante raros… Probemos a ampliar uno, a ver…

Efectivamente, es lo que había notado: pasa constantemente de «cargando» a «cargado» y viceversa, pero la batería no parece cargarse sino todo lo contrario… al menos hasta llegar al 30%, que parece haberse puesto a cargar por fin… Ampliemos ese trozo a ver…

Efectivamente, parece que va alternando entre carga y cargado, y de pronto empieza a cargar de nuevo. Pero parece que hay algo en el punto en el que se arregla. Ampliemos un poco más…

¡Bingo! No sólo está alternando constantemente entre ambos estados, sin cargar realmente la batería, sino que hasta que le di la orden de separarse de la base y volver a ella la batería no empezó a cargarse de nuevo correctamente.

Obviamente esto es bastante raro, y suena a bug: si alguien utiliza la aspiradora todos los días o cada dos días nunca notará este problema, pero si, como yo, la pasas cada tres días, entonces te encuentras con él.

Esto es un problema bastante serio, pues la aspiradora consume bastante batería en reposo al estar la WiFi constantemente encendida, por lo que, cuando empieza a hacer esto, la batería se consume relativamente rápido. Además, no parece haber un periodo exacto tras el que ocurre, sino que aparentemente empieza entre 24 y 48 horas después de la última vez que empezó a cargar.

Obviamente esto no me hacía ninguna gracia: no sólo puede suponer que cuando quiera usar la aspiradora ésta no esté lista, sino que, encima, descargarse tantísimo no es nada bueno para las baterías.

Pero, afortunadamente… ¡¡¡Tengo el poder!!! La aspiradora está conectada a MI servidor, lo que significa que puedo añadir algo de código para detectar esta situación. Es más, puedo añadir algo más de código para que cuando ocurra, automáticamente separe la aspiradora de la base y la vuelva a conectar. Y ya puestos, para que no haga demasiado ruido, poner el ventilador al mínimo durante la operación y restaurar el valor original justo después. Y dicho y hecho: ahora, cada vez que el servidor detecta que la batería pasa de «cargando» con una carga del 80% o menos a «cargado» tres veces seguidas, automáticamente da la orden de separarse de la base y conectarse de nuevo.

Ah, y el trozo de cartón es porque esa parte del suelo está muy pulida y resbaladiza, y las ruedas no siempre consiguen hacer tracción a la hora de separarse de la base.

Parte 12