Archivo de la categoría: Cacharreo

Daisy, Daisy…

Recientemente, Alva Majo, desarrollador de videojuegos indie y youtuber, publicó un vídeo donde explicaba cómo creó Alvabot, un sintetizador de habla que utilizaba muestras de su propia voz. Como, además, hizo público el código fuente y las muestras de voz, decidí que podía ser divertido intentar llevarlo un poco más allá y hacer que cantase. ¡Y lo he conseguido!

Sí, vale… el resultado no es exactamente profesional, pero para ser algo hecho en un par de días, creo que no está mal.

El código y las muestras de cantabot están disponibles en mi repositorio GIT bajo una licencia MIT/Expat (que viene a ser equivalente al «haz lo que te de la gana» con la que distribuyó Alva Majo su código original). También están disponibles las pequeñas utilidades que usé para reconstruir las muestras y adaptarlas para la tarea.

El proceso fue algo alambicado, pero al final no tiene mucha ciencia. Obviamente, si hubiese querido conseguir mejores resultados tendría que haber utilizado otras técnicas, pero la idea era hacer sólo un pequeño divertimento.

En primer lugar escribí una pequeña herramienta en Python (analizador.py, disponible en el repositorio) que me permite ver la forma de onda de un fichero WAV, escoger un trozo entre dos puntos concretos, calcular la frecuencia de dicha onda, y cortar entre los máximos de dicho trozo (todo esto lo explicaré mejor a continuación). Gracias a que Python tiene módulos para todo, no necesité programar casi nada, sino que ha sido más una operación «de pegar cosas».

Así, tomé los samples de las vocales y utilicé la herramienta anterior para revisar la forma de onda de cada una (en este artículo denominaré sample a cada fichero de audio con el sonido de una vocal o una consonante; y utilizaré el término muestra para referirme a cada uno de los datos de sonido que hay dentro de cada fichero; esto es: cada «número» dentro del fichero .WAV será una muestra).

Aquí vemos una ampliación de la vocal A, tal y como se ve en la herramienta:

Si nos fijamos, vemos que parece que hay cierta «repetición». O sea: la onda no es «aleatoria», sino que parece que hay un bloque que se repite en el tiempo (como se puede apreciar por esos picos espaciados de manera regular). Es verdad que la amplitud (la altura) no es constante, pero eso es simplemente porque Alva es un ser humano, y por mucho empeño que ponga es imposible que mantenga exactamente la misma intensidad durante toda la vocal, sino que inevitablemente subirá y bajará un poco en el tiempo.

Para verlo mejor, ampliemos un trozo:

Aquí ya se ve mucho mejor cómo la vocal es, en realidad, un trocito de sonido que se repite una y otra vez. En esta imagen concreta lo vemos repetido cinco veces. Esto significa que si creamos un sample con sólo ese trozo, podremos repetirlo en bucle y conseguir que el ordenador pronuncie una vocal durante todo el tiempo que queramos, sin que se noten cortes, ligaduras ni nada por el estilo. Esto es fundamental a la hora de cantar porque cada nota puede tener una duración diferente a las demás, y son justo las vocales las que se «estiran» en el tiempo para cubrir todo el tiempo que dura una nota.

Aquí es donde mi herramienta analiza.py me pide dos puntos en la gráfica. El primero tiene que estar un poco ANTES de uno de los máximos, y el segundo un poco DESPUÉS del siguiente máximo. Entonces, la herramienta busca hacia adelante el valor máximo local desde el primer punto, y hacia atrás desde el segundo punto, para así recortar con precisión el trozo periódico. Para la vocal A utilicé 1668 y 2023, marcados en rojo en la imagen, y que como vemos caen justo cerca de dos máximos consecutivos. El propio programa busca los máximos, los cuales están en 1697 y 2001.

Lo segundo que hice fue calcular la frecuencia fundamental de cada vocal. Para los que no sepan a qué me refiero, la frecuencia fundamental determina la «nota» en la que está sonando la vocal. Cuando mayor sea la frecuencia, más alta en la escala será la nota. Al principio hice algunas pruebas con la transformada de Fourier, hasta que me di cuenta de que no necesitaba complicarlo tanto, pues simplemente con saber el número de muestras que hay en el «trozo» repetitivo y la frecuencia de muestreo, ya puedo obtener la frecuencia fundamental del sample.

Así, por ejemplo, si decidimos utilizar para la vocal A el bloque situado entre los picos de las muestras 1697 y 2001, vemos que hay un total de 304 muestras en él, lo que significa que si dividimos la frecuencia de muestreo de este fichero .WAV (que es de 44100 muestras/segundo) entre dicho valor, nos dará que la frecuencia a la que Alva pronunció la letra A es de 145 Hz.

Repitiendo este proceso para las cinco vocales, sale que cada una fue pronunciada con estas frecuencias:

vocal A: muestras 1697 a 2001, 145 Hz
vocal E: muestras 555 a 855, 147 Hz
vocal I: muestras 2968 a 3242, 161 Hz
vocal O: muestras 2246 a 2544, 148 Hz
vocal U: muestras 3027 a 3317, 152 Hz

Esto demuestra que Alva Majo NO es un robot, pues no es capaz de afinar correctamente. Por eso cada vocal tiene una frecuencia diferente.

Pero claro, para nosotros esto es un problema porque si queremos que el ordenador cante, necesitamos que las muestras estén todas en la misma frecuencia. Si no, lo único que conseguiremos es un ordenador que desafina.

Afortunadamente, una vez que tenemos ya aislado el bloque periódico de cada vocal, ajustar su frecuencia es muy sencillo: sólo necesitamos hacer que todas midan lo mismo (pues la frecuencia fundamental depende del tamaño del bloque y de la frecuencia de muestreo; como la frecuencia de muestreo tiene que ser la misma para todos los samples, tenemos que conseguir que el número de muestras sea igual). Si calculamos la media de todas esas frecuencias, nos salen 150 Hz, así que ajustaremos todas a ese valor para evitar tener que desplazarlas demasiado. Y si ahora dividimos la tasa de muestreo entre esa cifra, nos sale que cada bloque debe tener 44100/150 = 294 muestras. Por supuesto, no se trata de añadir, por ejemplo, ceros al final, porque entonces estaríamos modificando la señal. Lo que necesitamos es «inventarnos» muestras en medio para estirarla si es demasiado corta, o «tirar a la basura» muestras si es demasiado larga. Por supuesto, si añadimos muestras éstas deben «encajar» con las que las rodean; y si quitamos muestras, debemos hacerlo de manera «repartida». Esto, como no, tiene un nombre: remuestreo o cambio de tasa de muestreo, y el módulo scipy incluye una función llamada resample() que lo hace. Así que en la parte final de la herramienta hago una llamada a dicha función para ajustar cada sample justo antes de grabarlo en disco.

Para poder evaluar los resultados, podemos comparar el antes:

con el después:

Ahora llega el momento de hacer lo mismo con las consonantes, pero esto es un poco más complicado, pues éstas no tienen por qué tener un patrón (aunque algunas sí), por lo que será difícil calcular su frecuencia… o no, porque, afortunadamente, Alva grabó todas las consonantes seguidas de todas las vocales. No tenemos sólo el fonema ‘P’, sino que también tenemos los sonidos de ‘PA’, ‘PE’, ‘PI’, ‘PO’ y ‘PU’, así que podemos medir la frecuencia a la que pronunció la vocal y asumir que es la misma que la del fonema de la consonante. Luego sólo tendremos que cortar justo en el punto donde termina la consonante y empieza la vocal, y tendremos el sonido puro de la consonante. Veamos un ejemplo con la sílaba «DA»:

Para hacer esto escribí otra pequeña herramienta, cortar_consonantes.py, la cual carga un fichero .WAV, muestra su forma de onda para que podamos buscar dos muestras que delimiten un periodo de la vocal, y nos pide que tecleemos su posición. Con ellas calcula la frecuencia a la que está pronunciada y también cuanto tiene que estirar o encoger todo el fichero WAV para ajustarlo a los 150 Hz que decidimos que sería nuestra frecuencia central.

Una vez que está ajustada, nos pide el número de muestra en la que realizar el corte para separar la consonante de la vocal. Como no es fácil acertar, lo que hice es que, tras dar un punto de corte, reproduzca la consonante seguida de la vocal «I» (para lo cual, utilizo los samples que generé antes). De esta manera, si se recorta demasiado poco y se cuela parte de la vocal original, sonará AIIIIIII en lugar de IIIIIII. Así podremos ir ajustando hasta encontrar el punto óptimo de cada consonante. Cuando hayamos encontrado el punto exacto, simplemente pulsamos RETURN en lugar de meter un nuevo número y el programa grabará el nuevo sample con sólo la consonante, ya ajustada a 150 Hz.

Algunas consonantes, pese a todo, son sonoras, lo que significa que SI están formadas por una zona repetitiva. En concreto, para la B, la M y la N utilicé el mismo sistema que para las vocales, pues se entendían mucho mejor que extrayéndolas «tal cual» de los samples.

Haciendo que hable

Ahora que ya tenemos los sonidos vocales y consonantes aislados y a la misma frecuencia, podemos hacer una primera prueba que se limite a hablar. Para ello escribí hablar.py, que generará un fichero habla.wav en el que pronunciará la frase que se le pase por la propia línea de comandos.

Lo primero que hace este programa es sustituir las letras por fonemas, de manera que no hace falta pensar en sonidos sino que podemos escribir directamente una frase. Además, si alguna vocal tiene tilde la pronunciará a una frecuencia ligeramente superior, lo que permite simular la entonación de las frases. En teoría se podría hacer que fuese completamente automático, y que si una palabra no tiene tilde, compruebe si acaba en n, s o vocal y, en base a ello, determine si es aguda o llana y ponga la tilde automáticamente, pero no me apeteció ponerme con ello, así que hay que hacerlo a mano.

Ejemplo: «Hóla, sóy cantabót hablándo.»

Y haciendo que cante

Y por último, está la herramienta final, cantar.py, que genera un fichero .WAV con una canción generada con las muestras.

El programa se compone de una clase con dos métodos fundamentales: interpreta() y pausa(). Estos dos métodos se deben ir llamando para introducir la partitura y la letra de la canción. Cada llamada renderiza los sonidos correspondientes, que se van añadiendo a un buffer interno. Cuando la canción está lista, se llama al método grabar(), que lo almacenará en disco en formato WAV con el nombre canta.wav.

El método pausa es el más sencillo, y simplemente recibe como parámetro un número decimal con el número de segundos de silencio que se deben añadir al buffer. Permite añadir silencios entre notas.

El método interpreta es el más complejo, y recibe tres parámetros:

  • Un entero con la octava de la nota a interpretar. Este valor estará entre 0 y 12 e indica el semitono concreto. La correspondencia exacta está en el código fuente
  • Un valor decimal con la duración en segundos de la nota.
  • Un string con los fonemas a cantar en dicha nota

Para simplificar la composición, se definen seis variables (redonda, redondadot, blanca, blancadot, negra y negradot) con las duraciones en segundos. Se establece la duración de la redonda en 1,5 segundos, y el valor de la blanca y la negra queda definido automáticamente como la mitad y la cuarta parte de la redonda. A mayores, las duraciones con «dot» son un 50% más que la correspondiente sin «dot». Además, se definen también las variables nota_XX, con el valor de semitono correspondiente a cada nota (por ejemplo, nota_sol vale 5).

Por otro lado, a la hora de decidir cuanto tiempo dura cada fonema de la nota se siguen las siguientes reglas:

  • Las consonantes durarán lo que dure su sample, salvo las consonantes «sonoras», que durarán 0,2 segundos
  • Las vocales que no sean la última durarán 0,15 segundos
  • La última vocal durará el tiempo necesario para completar la duración de la nota

Así, la última vocal durará realmente la duración de la nota menos la duración del resto de fonemas. Este valor se calcula automáticamente, lo que simplifica mucho meter una partitura.

Dado que ya nos preocupamos antes de ajustar todos los samples para que estuviesen a 150 Hz, ahora lo único que tenemos que hacer es ajustar la frecuencia de todos los fonemas a la necesaria para la nota. Y lo interesante es que para saber cuanto tenemos que ajustar la frecuencia, sólo necesitamos elevar la raíz doceava de 2 al valor del semitono que le pasamos a la función (por supuesto, tras restar 5, pues por convenio he decidido que los samples estarán en SOL).

¿Y por qué la raíz doceava de 2? En este vídeo de Minute Physics lo explican muy bien, pero para simplificar: como la frecuencia de una nota cualquiera tiene que ser la mitad de la frecuencia de la misma nota en la siguiente octava, y cada octava son doce semitonos, la única manera de que esto encaje es que la frecuencia de cada semitono sea la frecuencia del semitono anterior multiplicado por la raíz doceava de dos. Así que si quiero hacer sonar un LA (semitono 7 como vemos en la tabla de arriba), necesito subir la frecuencia de los fonemas en dos semitonos, o sea, multiplicar dos veces por la raíz doceava de dos. En cambio, si quiero interpretar un MI, tengo que bajar la frecuencia en tres semitonos; o sea, dividir tres veces entre la raíz doceava de dos.

¿Y cómo cambiamos la frecuencia de los fonemas? Pues exactamente como vimos antes: interpolando para estirarlos o encogerlos. Si los estiramos, la frecuencia baja; y si los encojemos, la frecuencia sube. Por tanto, una vez hemos calculado el factor entre la frecuencia inicial y la final, sólo tenemos que coger el número de muestras que tiene cada fonema y multiplicarlo por dicho factor, obteniendo así el nuevo número de muestras que tiene que tener. Con ese número llamamos a interpolate() para que haga su magia, y añadimos las muestras al buffer.

A ritmo de conga (17)

Desde que tengo mi robot aspirador, tengo que tener cuidado de dejar la puerta de la cocina cerrada cuando la uso, porque obviamente no es buena idea que se te cuele en un sitio donde siempre puede haber algo de grasa que se te haya caído, o así. Pero es un rollo tener que estar pendiente, así que decidí buscar una solución.

Lo primero que se me ocurrió fue comprar cinta adhesiva magnética, pues se supone que este modelo tiene un sensor, y cuando detecta un campo magnético asume que no tiene que entrar por ahí. Por desgracia, no funcionaba: la aspiradora pasaba por encima como si no estuviese.

Ante esto decidí buscar una alternativa, así que se me ocurrió que podía meter un relé reed conectado al sensor del parachoques, de manera que cuando pasase por encima de la cinta, el robot pensase que ha chocado. Así que aprovechando que se le acabó la garantía, decidí abrirla y echar un vistazo a ver si podía hacerlo.

Lo primero que me encontré es que mi suposición de que llevaba dos micros, el principal por un lado, y un ESP8266 (o ESP32, no fui capaz de verlo bien) conectado por un puerto serie, era acertada.

La segunda sorpresa fue descubrir que en el frontal tiene un sensor de efecto Hall.

Claro, esto era muy raro, porque se supone que estos sensores son, entre otras muchas cosas, para detectar cintas magnéticas en el suelo. La conclusión que saqué es que está capado por software, y está ahí porque al fabricante le sale más barato fabricar todas iguales y luego afinarlo mediante código.

También vi que los sensores del parachoques no eran microswitches, sino optointerruptores. El motivo supongo que será fiabilidad.

El siguiente paso fue comprar unos cuantos relés reed y hacer varias pruebas a ver qué tal tiraban, pues temía que tuviesen que estar demasiado cerca de la cinta para funcionar. La primera sorpresa fue que funcionan mucho mejor en perpendicular a la cinta magnética que en paralelo, hasta el punto de que en paralelo hay que pegar prácticamente el relé a la cinta para que funcione, pero en perpendicular se activa ya a unos dos/tres centímetros.

Pero claro, no puedo garantizar que el relé siempre esté perpendicular, pues si la aspiradora va de frente hacia la cinta magnética, estará en una posición, mientras que si va siguiendo el borde de la pared, estará girado 90 grados…

Al final, lo resolví poniendo dos relés reed en cada lado, conectados en paralelo y a 90 grados uno del otro. De esa manera siempre funcionará de manera óptima.

La conexión sería bastante sencilla: bastaría con conectarlos en paralelo con el sensor óptico del parachoques:

De esta manera, cada vez que la aspiradora se acerque a la cinta magnética del suelo, creerá que ha chocado contra la pared.

La siguiente cuestión era… ¿donde los monto? La primera idea fue pegarlos directamente por debajo, pero luego me di cuenta de que los relés reed son de vidrio y muy frágiles, por lo que no era muy recomendable. Por otro lado, el interior del aspirador está muy lleno, y no queda casi sitio. Al final, después de darle varias vueltas y de considerar incluso ponerlos debajo de las trampillas de los motores de las ruedas, decidí que la tapa de la batería era el mejor lugar, sobre todo porque la batería deja suficiente hueco a cada lado. Así que procedí a pegar los dos grupos de relés por dentro de la tapa con pegamento térmico:

Una vez montados ambos pares de relés, procedí a pasar los cables por el mismo agujero por el que pasan los de la batería:

Por último, procedí a soldar los cables a los sensores. MUY IMPORTANTE NO EQUIVOCARSE, y conectar cada par de relés al sensor óptico del mismo lado, pues si no, la aspiradora girará hacia el lado opuesto y acabará quedándose atrapada sobre la línea.

Y con esto se acabó, y ahora mi aspiradora ya no se cuela en la cocina.

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.

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

A ritmo de conga (9)

No esperaba escribir esta entrada, pero al final me animé. Resulta que, aunque estoy muy contento con mi robot aspirador, tiene un defectillo que no me convence nada, y es que cuando hay una alfombra y tiene que girar estando encima del borde, se suele atascar.

El motivo es que en la parte trasera, justo en el borde del depósito de suciedad/tanque de agua, hay una rueda para apoyarse. Ésta en concreto:

El problema con ella es que si la aspiradora está justo medio subida en una alfombra, con la mitad del cuerpo encima y la otra mitad fuera, y en ese momento gira sobre sí misma en lugar de avanzar, esa rueda tropezará contra el borde de la alfombra y no dejará que la aspiradora gire, sino que la empujará fuera. En un mundo ideal sería una rueda loca y no habría ese problema.

Pero como yo no soy de quedarme de brazos cruzados, decidí intentar solucionarlo.

Las primeras búsquedas de ruedas locas en internet no dieron ninguna solución adecuada: o eran muy grandes, o eran carísimas (¿en serio más de diez euros por una bola de acero y un soporte impreso en 3D?). Así que decidí buscar una solución más artesanal, al menos para intentar probar el concepto y ver si lo resuelve o no.

Al principio decidí buscar dos bolas de rodamiento de 15 milímetros de diámetro y hacer dos soportes con impresión 3D. Algo parecido a la rueda frontal del mClon. Esos soportes con las bolas irían montados a cada lado de la rueda trasera y sobresaldrían un milímetro más que ésta. Eso debería ser suficiente para salvar cualquier alfombra.

Sin embargo, tras hablar con mis compañeros de A industriosa, me indicaron que, realmente, esa rueda loca no gira demasiado y que además es bastante pesada, y que para lo que yo quiero, algo estático (un simple vástago redondeado) haría la misma función y sería más sencillo de construir.

Ante esto, me acerqué a mi Todo a 100 de confianza y empecé a buscar «cosas esféricas» que me pudiesen servir, y encontré unas pelotas de una goma dura que eran perfectas:

Lo primero fue cortarla a la mitad, de manera que midiese 18 milímetros. Esto lo hice fácilmente con un cutter.

El siguiente paso fue recortar un lateral para que encajase en la zona de la rueda actual, cubriéndola por completo.

Y por último, un poco de cola térmica para fijarla. La ventaja de la cola térmica es que se disuelve con un poco de alcohol, lo que me permitirá quitarla sin dejar rastro en caso de que no funcione.

Y el resultado es, sencillamente, perfecto: ya no se atasca cuando tiene que girar en el borde de una alfombra, sino que sube perfectamente sobre ella como si estuviese bañada en mantequilla. Aunque sí es cierto que se desgasta un poco, no creo que sea un problema, pues en último caso, cuando llegue a la rueda, empezará a apoyar en ella y se acabará el desgaste, pero con la ventaja de que estará rodeada por la goma esférica y seguirá cumpliendo la función de ayudar a sortear alfombras al girar.