Archivo de la categoría: Programación

Técnicas, ideas o trucos de programación

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 (8)

Esta será, en principio, la última entrada. En ella voy a describir el servidor/app que escribí para controlar mi aspiradora robot.

DISCLAIMER: no seré responsable si alguien decide seguir mis pasos y se carga su aspiradora. En principio todo lo que cuento debería ser seguro, pero por motivos obvios no me puedo responsabilizar de lo que hagan otras personas, sólo de lo que haga yo.

El código está escrito en python, está disponible en mi cuenta de Gitlab en servidor para robots Conga 1490, y se llama OpenDoñita. Esta descripción complementa a la del capítulo 5.

Para empezar, está cómo hacerlo funcionar. Yo lo he puesto en una Raspberry Pi 4 que tenía muerta de risa por casa (de momento OSMC no funciona en ella), la misma con la que configuré la red WiFi interna para analizar el tráfico. Sin embargo es posible ponerla en cualquier ordenador, siempre y cuando se configure un DNS adecuado. En efecto, si recordamos, la aspiradora se conectará siempre a dos dominios concretos, bl-app-eu.robotbona.com y bl-im-eu.robotbona.com, por lo que necesitamos engañarla de alguna manera para que se conecte a nuestro servidor. La manera más sencilla es entrar en la configuración de nuestro router Wifi y y configurar la IP de nuestra Raspberry Pi como la IP del servidor DNS de nuestra red. Luego, en dicha Raspberry instalamos dnsmasq, pero no configuramos la parte de DHCP, sólo el servidor DNS. Una vez hecho esto editamos el fichero /etc/hosts y añadimos estas dos entradas:

192.168.X.Y bl-app-eu.robotbona.com
192.168.X.Y bl-im-eu.robotbona.com

Siendo 192.168.X.Y la IP del equipo en donde vayamos a poner a correr el servidor (que, normalmente, será la misma Raspberry Pi).

Un detalle importante es que mi código necesita Python 3.6 o superior, lo que significa que funcionará en Raspbian, pero no en la versión actual de OSMC pues está basada en la versión stretch de Debian (lo he probado; quería meter todo en la misma Raspberry pero no ha podido ser). Hasta que actualicen el sistema base a buster (y me consta que están en ello) no se podrá hacer.

Una vez que está hecho reiniciamos dnsmasq para que refresque los valores, y ya podemos lanzar nuestro servidor. Para ello, desde un terminal, vamos al directorio donde esté el código y escribimos:

sudo screen ./congaserver

Es importante estar en el directorio correspondiente pues, por defecto, es ahí donde el servidor buscará el directorio html con las páginas de la aplicación web que se utiliza para controlar a las aspiradoras. Si lo lanzamos desde otro directorio lo más probable es que no funcione. Para más información sobre screen, recomiendo leer la documentación. Es necesario usarlo (o bien nohup en su lugar) para poder salir de la sesión SSH de la RPi y que el programa siga corriendo.

Podemos probar si funciona todo correctamente simplemente abriendo un navegador y escribiendo en un terminal

ping bl-app-eu.robotbona.com

Si todo está correcto, debería resolver a la IP de nuestra Raspberry Pi.

Y ahora que está todo listo y corriendo, ya podemos conectar la aspiradora. Para ello tenemos tres opciones:

  • Apagar la WiFi y volverla a encender. En este caso, la aspiradora volverá a conectarse a ella, buscará el servidor de nuevo y se conectará al nuestro, pues recibirá la IP desde nuestro servidor DNS.
  • Apagar la aspiradora y volverla a encender. Para ello es necesario retirarla de la base de carga y apagar el interruptor situado en el lado derecho, esperar unos segundos y volver a encenderlo.
  • Volver a emparejar la aspiradora. Es el más lioso, pues con el DNS en marcha la app oficial no podrá conectarse al servidor (usa un puerto diferente) y se negará a arrancar, por lo que tendremos que utilizar el script configconga.py, que explicaré más adelante.

En cualquiera de los tres casos sabremos si hemos tenido éxito porque en la pantalla de nuestro servidor aparecerán mensajes que indican que se están haciendo peticiones.

Una vez conectada, ya está todo listo para empezar a trabajar. Si ahora abrimos un navegador y metemos la IP del equipo de nuestro servidor, nos aparecerá la siguiente pantalla:

En la parte blanca de la derecha es donde aparecerá el mapa, pero de momento estará en blanco. Por otro lado, en la parte superior izquierda tenemos un indicador de carga de la batería. Cuando esta completamente negra estará cargada, y si está blanca estará descargada. Ahí se ve que está casi cargada del todo.

El símbolo del rayo indica que está cargando. Cuando esté cargada del todo (o cuando no esté en la base) dicho símbolo desaparecerá.

A continuación vemos los cuatro botones que podemos utilizar. El primero, con forma de casa, ordena a la aspiradora volver a la base. Sin embargo, si ya está en la base aparecerá sólo en forma de líneas, sin relleno (tal y como se ve en la captura).

El siguiente botón, con forma de «play», le indica a la aspiradora que comience a trabajar. Al hacerlo cambiará a un cuadrado, o sea, «stop».

El tercer botón, con forma de altavoz, permite activar o desactivar el pitido de aviso de la aspiradora. Tal y como está en la captura, está desactivado. Al activarlo sonará un pitido de aviso y el icono pasará a estar relleno.

El último botón, el engranaje, muestra la pantalla de configuración de modo de limpieza. Al pulsarlo aparece esta imagen:

Los cuatro superiores, con forma de ventilador, representan las cuatro potencias de aspiración (nada, eco, normal y turbo). Las cuatro siguientes, con forma de gota de agua, la cantidad de líquido que se utiliza para humedecer la fregona (nada, poco, normal, mucho). Un detalle importante es que no es posible escoger a la vez los modos «nada» de aspiración y fregona. Por último, los siete inferiores representan los modos de aspirado y fregado disponibles, aunque lo normal es utilizar el modo auto y no complicarse.

Lo mejor de todo es que recuerda el modo escogido incluso si se apaga la aspiradora, pues la configuración se guarda en nuestro servidor.

Para cerrar esta ventana basta con pulsar la flecha situada abajo a la derecha.

Si ponemos a funcionar la aspiradora, se irá generando poco a poco el mapa, cuyo tamaño se irá ajustando poco a poco a medida que se va calculando. Por eso al principio estará formado por círculos gigantescos, pero luego irán haciéndose más pequeños a medida que la superficie descubierta aumente. Así, al principio veremos algo así:

El protocolo REST

Y tras esto, paso a describir el protocolo REST utilizado por el servidor. Porque, efectivamente, la nueva app es una aplicación web, por lo que se puede actualizar y tunear sin necesidad de parar el servidor y volver a lanzarlo (lo que, además, exigiría volver a hacer que la aspiradora se conectase a él usando uno de los tres métodos comentados antes).

Los comandos que acepta son los siguientes. Para empezar, están los tres específicos del emulador del servidor de Robot Bona, que son:

/baole-web/common/sumbitClearTime.do
/baole-web/common/getToken.do
/baole-web/common/*

Los dos primeros devuelven los datos necesarios para emparejar la aspiradora con el servidor, y el tercero devuelve simplemente un OK.

Ahora vienen los comandos específicos del servidor. Estos son comandos que no afectan a la aspiradora, sino que son útiles para implementar la app.

/robot/list
/robot/XXXXX/getStatus
/robot/XXXXX/setStatus
/robot/XXXXX/getProperty?key=YYYYYY
/robot/XXXXX/setProperty?key=YYYYYY&value=ZZZZZZ

El primero devuelve una lista con el identificador de cada robot que está conectado ahora mismo al servidor. Este identificador se puede utilizar para enviar comandos a un robot específico si tenemos varios en casa.

Los otros cuatro son comandos de servidor. El parámetro XXXXX sería un identificador del robot al que va dirigido (obtenido con list), o bien puede ser all si queremos dirigirnos a todos. De hecho, actualmente la app no soporta discernir entre robots, y siempre envía los comandos a all. Si en el futuro me compro otro robot puede que lo implemente.

getStatus devuelve el estado actual de la aspiradora. Se trata de un diccionario con los datos enviados por la aspiradora al servidor, tales como el mapa, el nivel de batería, el modo actual de trabajo… Cada vez que la aspiradora envía una actualización, el servidor la almacena internamente, y es esta caché la que se envía aquí. Eso significa, por ejemplo, que si no estamos aspirando, el mapa que recibamos será el último que se envió, aunque haya sido hace horas. También si hubo algún error aparecerá su código en el campo correspondiente, aunque haga mucho que no se produjeron más errores.

setStatus permite modificar alguno de los valores anteriores, pero sólo en la caché del servidor. No afecta a la aspiradora. Existe para poder resetear algunos campos que se activan en casos muy concretos, como por ejemplo el campo error, y así poder detectar cuando se ha producido uno nuevo. Actualmente no se utiliza para nada.

getProperty y setProperty se utilizan para almacenar datos personalizados en el servidor, referidos a una aspiradora concreta. Actualmente se utilizan para almacenar la configuración deseada (potencia de succión, cantidad de agua en modo fregado, y modo de limpieza), de manera que aunque se apague la aspiradora, el sistema recordará la configuración deseada por el usuario. La forma de almacenarlo es como pares clave/valor, y siempre son strings.

Por último, la lista de comandos que van a la aspiradora. Son:

/robot/XXXXX/clean
/robot/XXXXX/stop
/robot/XXXXX/return
/robot/XXXXX/updateMap
/robot/XXXXX/sound?status=0|1
/robot/XXXXX/fan?speed=0|1|2|3
/robot/XXXXX/watertank?speed=0|1|2|3
/robot/XXXXX/mode?type=auto|gyro|random|borders|area|x2|scrub
/robot/XXXXX/notifyConnection
/robot/XXXXX/askStatus

clean, stop y return controlan la operación básica de la aspiradora: comenzar un ciclo de limpieza, parar, y volver a la base. No tiene más complicación.

updateMap envía un comando 131, de manera que en torno a una décima de segundo después tendremos la variable map actualizada, y podremos obtener su nuevo valor con /robot/XXXXX/getStatus.

sound permite activar (1) o desactivar (0) el pitido de aviso de la aspiradora. Teniendo en cuenta que cada vez que termina de cargar emite un pitido, yo personalmente prefiero apagarlo.

fan y watertank permiten escoger la potencia de aspiración y la cantidad de agua que se utilizarán durante el ciclo de limpieza.

mode permite escoger entre los siete modos de limpieza, aunque lo normal es utilizar auto.

notifyConnection emite un comando 400. En la app actual se emite nada más cargar la página, para avisar de que hay una app abierta.

askStatus emite un comando 98, el cual hace que unos milisegundos más tarde la aspiradora envíe una actualización de su estado. El servidor, al recibirlo, actualizará su caché y podremos leer los cambios con robot/XXXXX/getStatus.

Por último, cualquier petición que no coincida con este formato se asumirá que está pidiendo un fichero, por lo que se buscará en la carpeta html. Es así como se sirven las páginas HTML y las imágenes de la app.

Emparejando la conga a una red WiFi

Una vez que hemos cambiado el DNS veremos que la app oficial, la del móvil, ya no funciona. Esto es porque intenta conectarse al servidor oficial, y al no poder por apuntar ahora al nuestro, se niega a abrirse siquiera. Eso significa que no podemos utilizarla si necesitamos emparejar nuestro aspirador con otra red WiFi.

Afortunadamente, para eso está configconga.py. Es un programita que permite realizar la operación de emparejado. Para ello necesitamos un ordenador con WiFi.

Lo primero es poner la aspiradora en modo emparejamiento. Para ello hay que mantener pulsado el botón de encendido hasta que el piloto de WiFi empiece a parpadear y suene un pitido.

En este momento la aspiradora se ha convertido en un punto de acceso WiFi, con un nombre CongaGyro_XXXXXX (siendo XXXXXX el identificador único de la aspiradora). Ahora tienes que conectarte desde tu ordenador a esa WiFi (no tiene contraseña), y ejecutar el programa anterior con:

./configconga.py    WIFI_SSID    WIFI_PASSWORD

Siendo SSID el identificador de la red WiFi a la que quieres conectar tu aspiradora, y PASSWORD la clave de dicha red WiFi. Una vez hecho esto la aspiradora dejará de ser un punto de acceso, y pasará a conectarse a la WiFi indicada.

¡Ahora, a disfrutarlo!

Parte 9

A ritmo de conga (7)

DISCLAIMER: no seré responsable si alguien decide seguir mis pasos y se carga su aspiradora. En principio todo lo que cuento debería ser seguro, pero por motivos obvios no me puedo responsabilizar de lo que hagan otras personas, sólo de lo que haga yo.

En la anterior entrada paré tras comentar los distintos comandos de que dispone la aspiradora y del formato del mensaje de estado. Ahora llega el momento de explicar el formato de los mapas. Claro que debo ser sincero: hace unos días encontré un repositorio de Git donde Felix Engelmann explica ese formato para la aspiradora Proscenic 790T, la cual parece ser la misma que la Conga 1490, y aunque pasa muy por encima del protocolo en general, sí explica en detalle cómo funcionan los mapas, por lo que me ahorré el trabajo de descubrirlo por mi cuenta. Paso a limitarme a describir con mis palabras lo que explica ahí.

Cada vez que enviamos un comando 131 a la aspiradora, o bien cada 30 segundos aproximadamente si no hacemos nada (pero en ambos casos sólo si está aspirando), nos llegará un mensaje de la aspiradora con este formato:

{
  "version": "1.0",
  "control": {
    "targetId": "0",
    "targetType": "6",
    "broadcast": "0"
  },
  "value": {
    "noteCmd": "101",
    "clearArea": "0",
    "clearTime": "10",
    "clearSign": "2020-06-24-01-31-41-2",
    "clearModule": "11",
    "isFinish": "1",
    "chargerPos": "8,12",
    "map": "AAAAAAAAZABk0vwAaoDXAGpA1wBqgNcAqNL8AA==",
    "track": "AQAEADIxMzExMTEy"
  }
}

Aquí hay tres elementos que nos interesan: los campos chargerPos, map y track. Para entenderlos hay que saber primero que la aspiradora genera un mapa interno en forma de una cuadrícula, donde cada cuadrado que la compone puede estar en uno de varios estados (zona sin explorar, zona libre y obstáculo).

Tras hacer algunos cálculos y ver cómo trabaja la aspiradora, parece que cada elemento de la cuadrícula es un cuadrado de unos 20cm en el mundo real(tm). Esto es consistente con el modo area, en el que la aspiradora va limpiando cuadrados de 2×2 metros (o sea, bloques de 10×10 cuadrados de la cuadrícula). Y como la aspiradora tiene un diámetro de 32cm, vemos que hay cierto solape, lo cual es perfectamente lógico si queremos garantizar que aspire todo el polvo y suciedad. Por defecto, la cuadrícula que envía la aspiradora es de 100×100 elementos, lo que nos da un tamaño máximo de 20×20 metros para la casa, aunque también es cierto que el tamaño viene en la cabecera del bloque map, por lo que supongo que en casas grandes lo ajustará dinámicamente.

Sabiendo esto podemos empezar a analizar los campos. El más sencillo es chargerPos. Éste contiene las coordenadas de la cuadrícula del mapa en donde está situado el cargador, lo que permite a la aspiradora volver hasta él para recargar la batería.

El siguiente en complejidad es map: es fácil darse cuenta de que es una ristra de bytes codificado con base64, por lo que lo primero que hay que hacer es convertirlo a bytes. En Javascript se puede hacer con atob, y en Python con el módulo base64. Una vez hecho esto, tiramos los primeros 4 bytes (que no sabemos qué significan), y luego cada par de bytes son las coordenadas x e y del recorrido de la aspiradora. Así, el primer par de bytes son las coordenadas del mapa en donde empezó a limpiar, el siguiente par es la siguiente cuadrícula a la que se ha movido, etc. Dado que la cuadrícula, por defecto, es de 100×100, parece que no tendremos que preocuparnos de si son números en complemento a 2 (desde -128 hasta 127) o normales (desde 0 hasta 255).

Con este campo podremos saber la ruta que ha ido siguiendo la aspiradora por la cuadrícula.

El tercer campo es el más complejo, pues está comprimido utilizando una técnica Run-Length Encoding, que, afortunadamente, es relativamente sencilla. Para ello tenemos que fijarnos en los dos primeros bits de cada byte. Si ambos están a 1, entonces es un contador de repeticiones y el número de veces que se repetirá el siguiente byte viene indicado por los seis bits inferiores. Ojo, porque si el siguiente byte también tiene ambos bits superiores a 1 entonces también se debe tener en cuenta para el número de repeticiones, tomando los seis bits del primer byte y moviéndolos seis posiciones a la izquierda, para luego anexionar los seis bits del segundo byte. Y así sucesivamente, anexionando tantos bytes seguidos como haya con los dos bits superiores a 1. Así, si tenemos los bytes 0xC2 0xDA 0xAA, nos está diciendo que tenemos que repetir el byte 0xAA un total de 0x21A veces (0xC2 & 0x3F = 0x02; 0xDA & 0x3F = 1A).

¿Y qué pasa con los bytes que no tienen sus dos bits superiores a uno? Esos contienen el estado de cuatro cuadrados consecutivos de la cuadrícula del mapa. El formato es el siguiente:

  • 00: zona sin explorar
  • 01: zona explorada libre (o sea, suelo)
  • 10: obstáculo (una pared, un mueble, una pata de una silla, un programador tocando las narices…)

Así, un byte 0x16 sería 00010110 en binario, lo que se traduce en cuatro elementos de la cuadrícula: 00 01 01 10. Por tanto aquí tendríamos un bloque sin explorar, los dos siguientes a su derecha serían bloques explorados libres (suelo), y finalmente una pared.

Por otro lado, la secuencia 0xC8 0x55 se descomprimiría como 0x55 0x55 0x55 0x55 0x55 0x55 0x55 0x55, u ocho veces 0x55; o sea 4 x 8 = 32 bloques de suelo libre.

Entonces, si tenemos estas dos secuencias (mapa arriba y track abajo):

AAAAAAAAZABk0fIAAqnWAAqqqdYABqqp1QABJqqp1gDCqqnVAAEqqqnT0wA=
AQIKADIxOjE6MDMwMy86LzouNC40MTAx

Tras procesarlas tendremos esto:

donde vemos que el cargador está en el punto verde, vemos también largas zonas exploradas que están libres (puntos rojos), paredes que delimitan la habitación (puntos azules), y el recorrido que ha seguido la aspiradora (línea negra).

Y con esto se ha terminado el análisis del protocolo, y ya tenemos todo lo suficiente para hacer nuestro propio servidor y app, y es justo lo que he hecho. Pero mejor eso en la siguiente entrada.

Parte 8

A ritmo de conga (6)

Actualizado: añadidos dos comandos extra.

DISCLAIMER: no seré responsable si alguien decide seguir mis pasos y se carga su aspiradora. En principio todo lo que cuento debería ser seguro, pero por motivos obvios no me puedo responsabilizar de lo que hagan otras personas, sólo de lo que haga yo.

Ahora que ya tengo un servidor en marcha capaz de enviar comandos, llegó el momento de analizar todas las posibles opciones. Para ello puse otra vez el tcpdump a capturar y, desde la aplicación oficial, fui enviando todos los posibles comandos, anotando a la vez la hora a la que lo hacía para poder luego identificarlos. También modifiqué mi analizador de paquetes para que mostrase el instante de tiempo en que se emitía o recibía cada paquete.

La primera conclusión es que todos los comandos (a excepción, de momento, de los de mover la aspiradora manualmente, que en principio hay que enviarlos a través del puerto 8888 de la aspiradora; aún no probé qué pasa si lo envío por la conexión normal) se pueden ejecutar con este paquete (con un par de excepciones):

d1 00 00 00 | fa 00 c8 00 | 00 00 09 01 | 1a 27 00 00 | 00 00 00 00
{
  "cmd":0,
  "control":{
    "authCode":"yyyyyy",
    "deviceIp":"192.168.18.3",
    "devicePort":"8888",
    "targetId":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType":"3"
  },
  "seq":0,
  "value":{"transitCmd":"ID_COMANDO"}
}\n

Siendo ID_COMANDO un número que identifica la operación. En casi todos los casos, la aspiradora devolverá una respuesta, con la excepción de un par de ellos.

Los posibles comandos que ya tengo identificados son:

  • 98: pide una actualización del estado de la aspiradora. El aspirador no envía asentimiento. Sólo funciona mientras la aspiradora está en marcha.
  • 100: comienza a limpiar
  • 102: deja de limpiar
  • 104: vuelve a la base
  • 106: establece el modo de limpieza. Incluye el parámetro mode antes de transitCmd con uno de los siguientes valores:
    • 11: modo auto
    • 1: modo giro
    • 3: modo random
    • 4: modo edges
    • 6: modo area
    • 8: modo deep cleaning
    • 10: modo scrubbing
  • 110: establece la potencia del aspirador. Incluye el parámetro fan antes de transitCmd con uno de los siguientes valores:
    • 1: detenido
    • 2: normal
    • 3: turbo
    • 4: eco
  • 123: activa los sonidos de la aspiradora. Emitirá un pitido al ponerse en marcha, terminar de cargar, etc.
  • 125: desactiva los sonidos de la aspiradora.
  • 131: pide una actualización del mapa generado. Sólo funciona mientras la aspiradora está en marcha.
  • 145: establece la cantidad de agua en modo fregado. Incluye el parámetro waterTank depués de transitCmd con uno de los siguientes valores:
    • 255: no emitir agua
    • 60: flujo de agua bajo
    • 40: flujo de agua normal
    • 20: flujo de agua alto
  • 400: notifica que la aplicación se acaba de conectar. El aspirador no envía asentimiento.

A mayores hay al menos dos comandos más:

  • 139: sirve para poner la fecha y la hora y que debe ir acompañado del parámetro set_time, con formato AAAAMMDDhhmm000x, siendo AAAA en año (cuatro cifras), MM el mes, DD el día, hh la hora, mm el minuto (todos con dos cifras), y luego está esa x, que en algunos sitios es 0, en otros 1 y en otros 2, pero aún no encontré una regla para determinar su valor.
  • 127: sirve para actualizar el firmware, así que mi consejo: no se toca.

Cada vez que enviemos un comando, la aspiradora nos devolverá un asentimiento (con la excepción de los comandos 98 y 400). Éste será un paquete como éste:

db 01 00 00 | fa 00 00 00 | 01 00 00 00 | 1a 27 00 00 | 00 00 00 00
{
"version":"1.0",
"control":{
"targetId":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"targetType":"3",
"broadcast":"0"
},"value":{
"noteCmd":"102",
"workState":"5",
"workMode":"0",
"fan":"2",
"direction":"0",
"brush":"2",
"battery":"100",
"voice":"2",
"error":"0",
"standbyMode":"1",
"waterTank":"40",
"clearComponent":"0",
"waterMark":"0",
"version":"3.11.416(513)",
"attract":"0",
"deviceIp":"192.168.18.3",
"devicePort":"8888",
"cleanGoon":"2",
"extParam":"{\"cleanModule\":\"3\"}"
}
}

pero con el importante detalle de que el estado que devuelve es el anterior a la ejecución del comando. Esto es: si tenemos el sonido activado (que en el JSON con el estado de la aspiradora viene indicado con voice valiendo 2), y enviamos el comando 125, el estado que nos llegue en el ACK del comando tendrá todavía voice=2. Será justo después cuando la aspiradora nos enviará un paquete status, con el siguiente formato:

bc 01 00 00 | 18 00 00 00 | 01 00 00 00 | 1a 00 00 00 | 00 00 00 00
{
  "version":"1.0",
  "control":{
    "targetId":"0",
    "targetType":"6",
    "broadcast":"0"
  },"value":{
    "noteCmd":"102",
    "workState":"5",
    "workMode":"0",
    "fan":"2",
    "direction":"0",
    "brush":"2",
    "battery":"100",
    "voice":"1",
    "error":"0",
    "standbyMode":"1",
    "waterTank":"40",
    "clearComponent":"0",
    "waterMark":"0",
    "version":"3.11.416(513)",
    "attract":"0",
    "deviceIp":"192.168.18.3",
    "devicePort":"8888",
    "cleanGoon":"2",
    "extParam":"{\"cleanModule\":\"3\"}"
  }
}

donde esta vez sí vemos que voice vale 1. Este paquete debemos asentirlo con un ACK como éste, con el mismo número de secuencia (el cuarto entero de los cinco que forman la cabecera):

3c 00 00 00 | 19 00 c8 00 | 01 00 00 00 | 1a 00 00 00 | 01 00 00 00
{
  "msg":"OK",
  "result":0,
  "version":"1.0"
}\n

En el JSON del estado de la aspiradora tenemos los siguientes campos interesantes:

  • workstate: indica el modo actual en el que está la aspiradora, que puede ser:
    • 1: limpiando
    • 2: parada
    • 4: regresando a la base
    • 5: cargando la batería
    • 6: batería cargada
  • battery: un valor de 0 a 100 indicando el nivel de carga de la batería
  • voice: si vale 1, el pitido de aviso está desactivado; si vale 2, está activado

Parte 7

A ritmo de conga (5)

DISCLAIMER: no seré responsable si alguien decide seguir mis pasos y se carga su aspiradora. En principio todo lo que cuento debería ser seguro, pero por motivos obvios no me puedo responsabilizar de lo que hagan otras personas, sólo de lo que haga yo.

¡Victoria, victoria! ¡Mi conga ya me hace caso!

Lo que hice fue escribir un nuevo servidor, esta vez desde cero, que combina HTTP y el protocolo específico de la conga. El código he decidido no hacerlo público hasta que termine el análisis completo, no vaya a ser que meta la pata en algo…

El motivo de no utilizar el código de http.server que ya tenía ha sido, fundamentalmente, porque debido a todos los trucos que tuve que utilizar para que funcionase, al final no usaba casi nada, con lo que me compensaba escribir mi propio servidor. Además, al hacerlo yo todo con select y sockets podía integrar mucho mejor en un único hilo la gestión del puerto 80 y la del 20008.

Básicamente creé una primera clase, BaseServer, que tiene un socket y que gestiona las funcionalidades básicas de éste, incluyendo un método vacío para cada vez que se reciben datos nuevos, y otros métodos para gestionar cuando se cierra. Sobre esta clase he construido absolutamente todo.

Luego creé la clase HTTPConnection, la cual es la que gestiona una conexión HTTP concreta. Cada vez que se hace una petición HTTP, se crea un objeto de este tipo, y es él quien analiza lo que llega. Su función es la misma que el código que hice antes con http.server. Para gestionar esto está la clase HTTPServer, que es la que abre el puerto 80 y crea los objetos anteriores tras cada conexión.

De la misma manera están las clases RobotConnection y RobotServer, pero esta vez para gestionar las conexiones al puerto 20008. El tenerlo dividido así permite controlar varios robots a la vez (aunque no creo que lo implemente de momento, no está de más que la arquitectura subyacente lo esté preparada).

La clase RobotConnection tiene un método llamado send_command, que lo que hace es, básicamente, emitir exactamente las mismas secuencias que registré anteriormente, copiadas byte a byte. Esto, al final, es bastante sencillo porque todos los comandos se emiten con el mismo tipo de paquete, y sólo cambia el número de comando y, en un par de casos, un parámetro extra.

Por otro lado, en base al contenido del segundo, tercero y quinto enteros de los paquetes enviados por la aspiradora puedo saber si es un paquete que espera una respuesta por mi parte, o un ACK de un comando enviado por mí, por lo que en el método de recepción de datos me limito a hacer una lista de comparaciones para ver qué tengo que responder; y si recibo una combinación desconocida, la imprimo.

Todas estas clases están gestionadas por una clase maestra llamada Multiplexer, que lanza primero una instancia de HTTPServer y otra de RobotServer, y va gestionando las nuevas instancias creadas por estos dos objetos mediante select.

Por último, hay una clase llamada Robot que está asociada permanentemente con un robot concreto, aunque éste no esté conectado. De momento se crea bajo demanda, pero en el futuro podría incluir algún tipo de persistencia, por ejemplo para que recuerde la configuración deseada por el usuario o los mapas, aunque se apague la aspiradora. Cada vez que un robot se conecta, se utiliza su identificador único para encontrar el objeto correspondiente y enlazarlo, y cada vez que se pierde la conexión, se informa de ello al objeto Robot para que borre la referencia al objeto RobotConnection.

Para la primera prueba decidí aprovechar el mismo servidor web que ya tengo para los robots e implementar un control sencillo por REST, mediante una sencilla página web. Es justo lo que se ve en el vídeo.

El siguiente paso es analizar y añadir los comandos para configurar el método de trabajo, la potencia del ventilador y el uso de la fregona, y por supuesto el control manual (aunque ya adelanto que parece que es en este caso donde entra en juego el puerto 8888 y la conexión directa con la tablet… a ver si hay suerte y me la puedo saltar).

También quiero analizar cómo se transmite la información de los mapas, pero eso será al final.

Parte 6

A ritmo de conga (4)

DISCLAIMER: no seré responsable si alguien decide seguir mis pasos y se carga su aspiradora. En principio todo lo que cuento debería ser seguro, pero por motivos obvios no me puedo responsabilizar de lo que hagan otras personas, sólo de lo que haga yo.

Hay que reconocer que cuando las cosas se complican, se complican de verdad. No se si lo que ha pasado demuestra que el programador que lo hizo es un genio y metió una sutil protección antihacking, o un chapuzas que hizo un código que funciona de milagro.

Me explico: decidí hacer una prueba rápida de la conexión de la aspiradora con un servidor mío, así que escribí un miniservidor con Python3 que escuchaba en el puerto 80, y que cuando recibía una petición a alguno de los documentos a los que responde el de bona robots (/baole-web/common/sumbitClearTime.do y /baole-web/common/getToken.do), anotaba los valores que pasaban y respondía lo que la aspiradora esperaba.

Pero claro, la aspiradora se empeña en conectarse a bl-app-eu.robotbona.com o a bl-im-eu.robotbona.com, así que tenía que engañarla para que se conectase a mi ordenador y no al servidor chino. La solución consistió en dos pasos:

  • primero, editar el fichero /etc/hosts de la Raspberry Pi que usaba como Access Point para conectar la aspiradora, y definir ahí esos dos dominios (además de robotbona.com, por si acaso), de manera que apuntasen a mi ordenador. Si la IP del ordenador fuese 192.168.0.89, habría que añadir:
192.168.0.89 bl-app-eu.robotbona.com
192.168.0.89 bl-im-eu.robotbona.com
192.168.0.89 robotbona.com
  • relanzar dnsmasq en dicha Raspberry Pi con sudo systemctl restart dnsmasq, para que lea los cambios.

Y con esto, cuando la aspiradora se conecte a dicha red y pida la dirección IP de esos dominios, éste le devolverá la de mi equipo, con lo que lo usará como servidor en lugar del real. Por supuesto, cuando termine todo esto habrá que montar el servidor en la Raspberry y que todo se conecte a la WiFi normal, pero eso ya lo haré en el último capítulo.

Ahora bien, como utilicé el módulo http.server, mi programa no implementaba la parte del puerto 20008 (donde realmente se reciben todos los comandos), y como para ello tenía que hacer más cosas, decidí, de momento, no complicarme la vida para una simple prueba, y limitarme lanzar un netcat en dicho puerto en un terminal. Así, cuando la aspiradora se conectase, vería por la pantalla lo que ésta enviase.

Así pues, apagué y encendí la aspiradora, la puse en modo emparejamiento, la emparejé desde mi ordenador, ésta apagó su Access Point, se conectó a la WiFi, hizo la petición sumbitClearTime.do, mi servidor respondió, hizo luego la petición getToken.do, mi servidor respondió también… y volvió a pedir getToken.do… Y otra vez lo volvió a pedir… Y el netcat sin inmutarse.

Algo raro ocurría, porque después de pedir getToken.do, netcat debería mostrar una conexión, pero ahí no ocurría nada. Decidí probar a quitar la redirección en el DNS por si acaso le había pasado algo a la aspiradora, de manera que se conectase al servidor real, y entonces funcionó de nuevo.

Esto era muy raro, así que volví a configurar mi DNS y lancé el tcpdump en la Raspberry para capturar el tráfico que se intercambiaba entre la aspiradora y mi servidor, y ver qué podía estar pasando. Tenía que ser algo en la segunda orden, porque la primera sí funcionaba bien.

Comparé las respuestas de mi servidor simulado con las del servidor real y eran idénticas: los mismos campos, el mismo orden… ¡Espera, había una diferencia sutil entre los JSON que yo enviaba y los que enviaba el servidor! En mi caso, en getToken.do utilizaba las funciones de python para convertir de un diccionario a JSON, y éste lo estaba embelleciendo añadiendo espacios detrás de las comas, de los dos puntos, etc. Sin embargo, en sumbitClearTime.do generaba la cadena «tal cual», por lo que no tenía espacios, igual que la que enviaba el servidor. Aunque un JSON debería funcionar igual con ellos que sin ellos, probablemente quien escribió el código que lo analizaba en la aspiradora podría haber hecho la chapuza de esperarlo de una determinada manera (o bien exigirlo así para detectar hackeos), así que eliminé ese trozo de código y generé la salida «a pelo», juntando cachos de código. Ahora sí funcionaría.

Volví a lanzar todo, encendí la aspiradora, me senté a ver en pantalla las conexiones al servidor… y volvió a fallar otra vez. Había alguna otra diferencia. ¿Pero cual?

Volví a revisar todo, y me di cuenta de que mi código estaba enviando una línea con el nombre del servidor. En concreto, el campo Server valía BaseHTTP/0.6 Python/3.8.3, mientras que el servidor real no enviaba dicho campo.

El problema es que el módulo http.server no permite eliminar ese campo… ¿Como resolverlo? La solución consitió en sobrescribir el método send_header del objeto, de manera que ignorase dicho campo. Esto funciona porque el módulo la usa para añadir sus campos propios. Así pues, añadí esto al servidor:

def send_header(self, token, data):
    if token != 'Server':
        super().send_header(token, data)

¡Y con esto por fin funcio…! No, seguía fallando. No era eso tampoco.

Hmm… En el código de error estoy enviando, después del 200, el texto OK, mientras que su servidor no envía nada, sólo hay un espacio detrás del 200 y ya luego el retorno de carro. ¡Seguro que es eso!

¡Agh, tampoco! No lo entiendo, estoy enviando exactamente lo mismo que su servidor: tanto las cabeceras como el contenido es idéntico. ¿Por qué no funciona? Si abro el wireshark y comparo las capturas reales con las mías a nivel de byte… son iguales. ¿Qué demonios está pasando? Es exactamente lo mismo, con la única excepción de…

No…

No habrán sido capaces…

No pueden haberlo hecho…

Lo fueron. Resulta que su servidor envía en un solo paquete la cabecera HTTP y el primer bloque de los datos en formato chunk, y en un paquete diferente el segundo y último bloque de los datos chunk; o sea, esto va en un paquete IP:

HTTP/1.1 200 \r\n
Date: Sat, 27 Jun 2020 16:38:08 GMT\r\n
Content-Type: application/json;charset=UTF-8\r\n
Transfer-Encoding: chunked\r\n
Connection: close\r\n
Set-Cookie: SERVERID=abcdefghijklmnopqrstuvwxyz12345|1234567890|1234567890;Path=/\r\n
\r\n
2b\r\n
{"msg":"ok","result":"0","version":"1.0.0"}\r\n

Y esto va en el siguiente paquete IP:

0\r\n
\r\n

Sin embargo, mi servidor enviaba en un paquete la cabecera HTTP, y el resto en un paquete diferente. O sea, esto iba en un paquete:

HTTP/1.1 200 \r\n
Date: Sat, 27 Jun 2020 16:38:08 GMT\r\n
Content-Type: application/json;charset=UTF-8\r\n
Transfer-Encoding: chunked\r\n
Connection: close\r\n
Set-Cookie: SERVERID=abcdefghijklmnopqrstuvwxyz12345|1234567890|1234567890;Path=/\r\n
\r\n

Y esto en el siguiente:

2b\r\n
{"msg":"ok","result":"0","version":"1.0.0"}\r\n
0\r\n
\r\n

Obviamente, en condiciones normales eso daría igual, pues TCP es un protocolo orientado a byte y se supone que da igual que un trozo viaje en un paquete IP y otro en el siguiente, pero es que esa era la única diferencia que quedaba, y efectivamente, en cuanto cambié el código para que lo emitiese de esa manera, por fin funcionó y se conectó al puerto 20008. ¿Será algún tipo de protección antihacking, o simplemente que, a nivel de programación del microcontrolador hicieron alguna chapuza? Sin analizar el firmware no puedo saberlo, y la verdad, es una tarea que, en este momento, no me llama nada. Pero da igual, porque lo importante es que…

Parte 5

Desktop icons, Gnome shell y Wayland

Hace cosa de un año y medio la gente de Gnome anunció que eliminaba de Nautilus todo el código que pintaba los iconos de escritorio. Para los que no se quieran leer el tocho, resumiré rápidamente que el motivo era, básicamente, que dicho código venía de muchos años atrás, era extremadamente complejo debido a las limitaciones de las versiones antiguas de GTK, y cada vez era más difícil de mantener a la vez que se añadían nuevas características al resto de Nautilus. Además, hacía ya seis años que Gnome 3 no utilizaba iconos de escritorio. Por si fuera poco, tenía muchos bugs de difícil solución (por ejemplo, el soporte multimonitor no funcionaba demasiado bien). Y a todo esto había que sumar el hecho de que en Wayland no funcionaría correctamente debido a las limitaciones que impone en aras de la seguridad: en efecto, en Wayland una aplicación no puede decidir donde colocar una ventana ni mantenerla fija en el fondo, entre otras cosas. El motivo de esta limitación es evitar que una aplicación maliciosa pueda hacerse pasar, por ejemplo, por el escritorio, o por una barra de tareas, etc, poniendo en riesgo la seguridad del sistema. Por desgracia, esto también significa que, en Wayland, cosas como las barras del escritorio, un dock o los iconos de escritorio no se pueden delegar en una aplicación, sino que tienen que ser manejadas desde dentro del gestor de ventanas. Es por esto que se creó la extensión de Gnome Shell llamada Desktop Icons: para seguir ofreciendo iconos en el escritorio en Gnome Shell para aquellos que lo deseasen (como yo).

La versión disponible en aquel momento ya implementaba la funcionalidad más básica, pero tenía un defecto que, para mí, era muy grave: no permitía utilizar una única pulsación para lanzar un icono, sino que obligaba a utilizar doble click. Como yo estoy acostumbrado a la primera manera, me lié la manta a la cabeza y envié un parche para implementarlo. Tras muchos cambios para adecuar el estilo de código al que se utiliza en el proyecto Gnome y más cosas (nunca podré agradecer lo suficiente a Carlos Soriano su infinita paciencia enseñándome a manejar Git en condiciones), lo aprobaron. Y le cogí el gustillo, con lo que detrás de él vino otro, y otro, y otro más… Hasta que, recientemente, me ofrecieron ser el mantenedor del código (lo que para mí es un honor, todo sea dicho).

Un año después, la extensión ya incorpora todo lo que se pretendía para la versión 1.0 y más, y se incluye por defecto en la versión de Gnome Shell de Ubuntu, y no puedo menos que agradecer a toda la gente que ha colaborado con parches e informes de bugs.

Por desgracia, a medida que más y más usuarios la han ido instalando, han ido surgiendo algunos problemas inherentes al hecho de que sea una extensión, problemas que no eran nada evidentes al principio y que sólo se han ido haciendo visibles a medida que la cantidad de usuarios crecía.

El primer gran problema es que todas las extensiones se ejecutan en el mismo bucle principal que el compositor de ventanas. Esto significa que una extensión que necesite mucho tiempo para ejecutar una operación puede, literalmente, congelar todo el escritorio, incluyendo el mismísimo repintado de todas las ventanas. En el caso de extensiones «normales», que se limiten a mostrar un icono en la barra de tareas o así, esto no es un problema, porque el trabajo que realizan es mínimo; sin embargo, en el caso de Desktop Icons sí lo es, pues cada vez que tiene que refrescar el escritorio (por ejemplo porque se añade o borra un fichero), tarda en torno a medio segundo en realizar todas las operaciones (leer la lista de ficheros en el directorio, obtener sus metadatos, generar los pixmaps, eliminar las cuadrículas e iconos previos, generar una nueva cuadrícula, crear los nuevos iconos, y pintarlos en su sitio), y durante ese tiempo todo el escritorio se congela. Obviamente, por que ocurra una vez cada mucho no es muy problemático, pero sin duda es molesto para el usuario.

Solucionar esto, aunque no es totalmente imposible, no resulta sencillo: actualmente ya utilizamos funciones asíncronas en todos los sitios posibles para evitar bloquear la cola de eventos, pero no es suficiente. Además sería necesario evitar repintar todo el escritorio y sus elementos, y sólo añadir o quitar los iconos de los ficheros que se hayan añadido o eliminado. Pese a todo, esto sólo reduciría un poco más el problema, pero no lo resolvería del todo, pues si un programa añade y borra constantemente un fichero al escritorio, por ejemplo, puede aún bloquear la cola, en buena medida porque algunas operaciones de repintado se realizan sólo cuando ya no quedan operaciones pendientes en la cola de eventos. Además, implementar esto implicaría un importante rediseño interno, y teniendo en cuenta que algunas distribuciones cuentan con Desktop Icons para sus versiones de soporte a largo plazo, no es algo que se pueda hacer de cualquier manera, sino que debe implementarse de manera muy progresiva y con una buena revisión por pares de todos y cada uno de los parches, para garantizar que no hay errores ni regresiones.

Otro problema es la imposibilidad (al menos actualmente) de integrar Drag’n’Drop al completo: aunque dentro de Gnome Shell existe soporte de Drag’n’Drop, no es compatible con operaciones desde el «espacio de usuario»; esto es: una aplicación no puede ni enviar al compositor, ni recibir de él, eventos de Drag’n’Drop . Aunque en principio sería posible implementar dicha comunicación, no es una tarea trivial, y de hecho, tras tantear a algún programador de Mutter, la conclusión es que es lo suficientemente complicado como para que sólo valga la pena pasar el trabajo de implementarlo en Wayland, pero no en X11.

Y de aquí llegamos al tercer problema: las extensiones se escriben con Clutter + St, los cuales no funcionan exactamente igual que Gtk. Un ejemplo es la forma en que se procesan los eventos de enter_notify y exit_notify, que obligó a añadir una serie de trucos en el código que permite seleccionar un grupo de iconos mediante «goma elástica», para gestionar correctamente aquellos casos en los que el cursor entraba en una ventana en mitad de una selección, o cuando pasaba por encima de la barra superior de Gnome. Esto es algo que en Gtk está resuelto desde hace años, gracias al mayor número de usuarios y programadores que trabajan con él, y que permite detectar más bugs.

El cuarto problema radica en los cambios entre versiones del escritorio. Dado que Gnome Shell no ofrece una API estable para las extensiones, éstas tienen que interactuar directamente con el código interno, por lo que cualquier cambio les puede afectar. En el caso de una extensión tan compleja como Desktop Icons este problema es aún mayor, hasta el punto de que la actual versión 19.01 será, probablemente, la última compatible con Gnome Shell 3.30 y 3.32, y las nuevas versiones necesitarán como mínimo Gnome Shell 3.34.

A la vista de todos estos problemas, hace un par de meses empecé a sopesar la posibilidad de hacer un cambio radical en el diseño y mover toda la lógica a un proceso independiente del compositor, de manera que toda la gestión de los iconos del escritorio se realice sin interferir con la composición y demás, además de utilizar Gtk directamente en lugar de St y Clutter. De hecho, cuando se decidió crear la extensión, los autores originales (Carlos Soriano y Ernestas Kulik) sopesaron seriamente esta misma posibilidad, pero lo descartaron precisamente por las limitaciones impuestas por el modelo de seguridad de Wayland, y porque hacerlo dentro de una extensión tenía más sentido en aquel momento, pues era muchísimo más fácil.

Por supuesto, esto es más sencillo de decir que de hacer, pues, como ya dije antes, aunque en X11 no hay ningún problema en que una aplicación mantenga una ventana en el fondo, o que la elimine de la lista de ventanas para que no aparezca al hacer Alt + Tab, en Wayland eso es totalmente imposible por diseño. Además, dado que las extensiones están escritas en Javascript, no es posible lanzar código en un nuevo thread, y aunque se pudiese, no sería posible que dicho código llamase a funciones de Clutter o St, por lo que hay que descartar la idea de descargar el trabajo en un thread paralelo. Lanzar un proceso independiente sí es posible, pero éste trabajaría desde fuera del compositor, por lo que, en principio, seguiríamos con el mismo problema.

Sin embargo, existe una alternativa extra, que es justo la que decidí investigar, que consiste en repartir el trabajo: por una parte tenemos un programa normal, que trabaja desde fuera del compositor y que utiliza GTK para gestionar absolutamente todo lo relacionado con el escritorio y sus iconos mediante una o varias ventanas normales, exactamente igual a como hacía el viejo Nautilus en el modo «iconos de escritorio»; y, por otro lado, una pequeña extensión cuyo trabajo consiste en lanzar el programa anterior (y relanzarlo cada vez que se muera), detectar la ventana que abre, y asegurarse de que ésta se mantenga donde debe. De esta manera, el código dentro de la extensión es mínimo y se limita exclusivamente a las operaciones que son imposibles de realizar desde el exterior del código del compositor.

Por supuesto, es fundamental no romper el modelo de seguridad de Wayland, lo que significa que no se puede permitir bajo ningún concepto que una aplicación extraña se pueda aprovechar de este mecanismo para colocar su propia ventana como el fondo del escritorio (o como cualquier otro elemento). Para ello, la única solución es que sea la extensión quien lance el proceso, y que cada vez que aparezca una ventana, compruebe si ésta pertenece al proceso que ella misma ha lanzado, otorgándole esos privilegios exclusivamente en el caso de que así sea. Dado que el código ha sido lanzado específicamente por la extensión, se puede considerar que es código tan confiable como el de dicha extensión (y más si el programa a lanzar forma parte de la propia extensión): si un programa malicioso puede reemplazar el código de la aplicación de escritorio, también podría hacerlo directamente con el código de la extensión, por lo que el nivel de seguridad es exactamente el mismo.

Ahora llega la cuestión de cómo detectar que una ventana pertenece al proceso lanzado desde la extensión. Mi primera idea fue utilizar metawindow_get_pid() en cada ventana nueva que apareciese, para obtener el PID del proceso que creó la ventana y compararlo con el PID del proceso que hemos lanzado desde la extensión; por desgracia, dicha llamada sólo funciona en X11 pero no en Wayland, porque utiliza un dato específico de X11. Sin embargo, existe otra llamada, metawindow_get_client_pid(), que sí funciona desde ambos sistemas; por desgracia es privada, lo que significa que sólo se puede llamar desde C y desde dentro de mutter, nunca desde una extensión escrita en Javascript.

Propuse entonces en el canal IRC de Gnome Shell que dicha llamada se cambiase a pública, pero mi idea no convenció porque existen varios ataques que involucran PIDs de procesos, por lo que, aunque yo hiciese las cosas bien, hacer pública dicha llamada podría suponer abrir la caja de Pandora. Sin embargo, sí me redirigieron al código de XWayland para que viese ahí como lo hacen de manera segura: básicamente, al lanzar el proceso crean manualmente un socket y asignan un extremo a Wayland, pasando el otro extremo al proceso hijo para que se comunique a través de él; luego basta con engancharnos a la señal map() y, por cada ventana que aparezca, comparar si su socket coincide con el que creamos nosotros para nuestro proceso hijo, en cuyo caso podemos estar seguros de que esa ventana pertenece a él y no a otro.

La idea era buena, pero por desgracia no se puede implementar directamente en Javascript porque precisa de varias llamadas privadas de mutter, además de que tampoco se pueden crear sockets desde Javascript. Ante esto me sugirieron escribir una clase GObject que lo implementase y exportase una interfaz para ello, y así lo hice: mi primer parche para mutter y mi primera clase GObject. Esta clase sólo funciona con Wayland (para X11 no tiene sentido, pues la propia aplicación puede detectarlo y hacer ella misma el trabajo), y permite lanzar un proceso y detectar si una ventana concreta pertenece o no a dicho proceso. Si se utiliza desde X11, la parte de lanzar el proceso también funciona, pero el método de detectar si una ventana pertenece o no al proceso genera una excepción (que, obviamente, se puede capturar). Esto permite simplificar el código en la extensión a la hora de hacer que funcione en ambos entornos de ventanas.

Aunque dicho parche funciona bien, aún sigue pendiente de aprobación; pero yo quería poder usar YA la nueva versión de Desktop Icons, así que, como a cabezón no me gana nadie, decidí ver qué podía hacer para detectar de manera segura la ventana de un proceso utilizando sólo lo que ya tenía disponible en Javascript. Para ello se me ocurrió que la aplicación de escritorio podría poner como título de la ventana una cadena concreta que la extensión pudiese identificar (tiene que ser en el título, pues no parece haber absolutamente nada más en una ventana que se pueda asignar de manera libre por el programa y que una extensión pueda leer). El problema es que dicha cadena no puede ser predecible, pues entonces otras aplicaciones podrían hacerse pasar por la legítima. Ante esto, decidí que la extensión generaría un UUID aleatorio de 128 bits justo antes de lanzar el proceso, y se lo pasaría a éste para que lo ponga en el título de la ventana (y, por supuesto, calculando uno nuevo cada vez que la aplicación se muera). Pero claro, pasarlo como parámetro por la línea de comandos sería completamente inseguro porque cualquier programa puede leer los parámetros de cualquier otro proceso (basta hacer un ps ax o leer /proc), por lo que tenía que ser algo más seguro. Al final la solución consistió en pasarlo a través de stdin, de manera que nadie más pueda leerlo (habría sido más elegante utilizar un pipe específico, pero por desgracia desde Javascript no es posible crear nuevos pipes).

Para simplificar aún más el código escribí una pequeña clase Javascript que es compatible a nivel de métodos con la clase GObject de mi parche, de manera que, si se aprueba, sólo tendré que reemplazar una clase por otra en el código (o incluso utilizar una u otra en función de la versión de Gnome Shell).

Con esto resolví el primer problema, pero ahora quedaba el segundo: aunque ya puedo identificar qué ventana es la del escritorio ¿como hago para mantenerla donde debe estar?

La solución elegante sería cambiar el tipo de ventana a uno de los tipos estándar (en concreto, a META_WINDOW_DESKTOP). Por desgracia, desde Javascript no es posible cambiarlo, pues la llamada es privada. Obviamente preparé un parche para cambiarlo, donde, además de hacerla pública, también la renombro para que sea consistente. Este cambio convenció mucho más en el canal IRC, por lo que espero que sea finalmente aceptado junto con el otro, pues la ventaja de estos dos parches es que no son específicos para este proyecto, sino que permiten, en general, «externalizar» el trabajo que actualmente se realiza dentro del compositor, lo que permitiría que más elementos, como por ejemplo la barra superior o un dock, puedan ser gestionados desde un proceso externo.

Sin embargo, seguía queriendo poder utilizar YA el programa, así que lo que hice fue engancharme a varias señales para forzar la ventana a permanecer en su sitio:

  • raised: esta señal se emite cada vez que una ventana pasa a primer plano. En su callback llamo al método lower(), que se encarga de mandarla debajo de todas las demás ventanas, manteniéndola así al fondo.
  • position-changed: como su nombre indica, esta señal se emite cuando el usuario cambia la posición de una ventana. En el callback la devuelvo siempre a donde le corresponde. Y es que, aunque la ventana del escritorio no está decorada (y, por tanto, en principio el usuario no tendría donde pinchar para moverla), sigue siendo posible utilizar combinaciones de teclas para cambiarla de sitio, cosa que no se puede permitir.

A mayores llamo a stick() para que la ventana aparezca en todos los workspaces.

Con esto ya se puede conseguir que la ventana permanezca siempre en su sitio, pero aún queda por evitar que aparezca en la lista de ventanas. De no hacerlo, aparecerá en el modo Actividades de Gnome Shell y en el cambiador de ventanas (el de Alt + Tab). Para solucionar esto hay que reemplazar tres métodos:

  • Meta.Display.get_tab_list()
  • Shell.Global.get_window_actors()
  • Meta.Workspace.list_windows()

Con estos tres métodos, la ventana desaparece «lo suficiente» como para que sea usable (por ejemplo, en Dash to Dock no desaparece, pero es un mal menor). Por supuesto no es muy elegante, y el resultado será perfecto si se acepta el parche para cambiar el tipo de ventana.

Y de esta manera tan horrorosa (aunque sólo hasta que aprueben mis parches… si es que los aprueban, claro) conseguí mover toda la funcionalidad del escritorio fuera del compositor, lo que resuelve de un plumazo todos los problemas anteriores. El resto del trabajo fue, básicamente, convertir los widgets de St y Clutter en los equivalentes de Gtk, quitar mucho código asíncrono que ya no era necesario y sólo complicaba terriblemente la lógica, y algunos detalles a mayores como añadir código extra en la extensión para que, durante el arranque, le comunique cuantos monitores hay, así como sus coordenadas y tamaños.

La extensión ya está disponible en la página de extensiones de Gnome Shell, y ofrece, además de todo lo que ya tiene la extensión original, Drag’n’Drop, no congelar la composición del entorno gráfico cuando se refresca el escritorio, más velocidad, mostrar los nombres de ficheros demasiado largos cuando se pase por encima el ratón, y más.

Ventana transparente en Gtk 3 y Javascript

Hacer una ventana con el fondo transparente utilizando Gtk es un clásico; de hecho hay ejemplos de como hacerlo en python, en C y en Vala. Pero falta como hacerlo con Javascript, y dado que es el lenguaje de moda para Gnome (y también por una serie de circunstancias extra) me he decidido a hacer el ejemplo. Este es el código:

#!/usr/bin/env gjs

imports.gi.versions.Gtk = '3.0';
const Gtk = imports.gi.Gtk;
const Gdk = imports.gi.Gdk;

Gtk.init(null);

let window = new Gtk.Window();
window.set_app_paintable(true);
let screen = window.get_screen();
let visual = screen.get_rgba_visual();
if (visual && screen.is_composited()) {
    window.set_visual(visual);
    window.connect('draw', (widget, cr) => {
        Gdk.cairo_set_source_rgba(cr, new Gdk.RGBA({red: 0.0,
                                                    green: 0.0,
                                                    blue: 0.0,
                                                    alpha: 0.0}));
        cr.paint();
        return false;
    });
    window.connect('delete-event', () => {
        Gtk.main_quit();
    });
    window.show_all();
    Gtk.main();
} else {
    print("El entorno de ventanas no admite transparencia");
}

Aunque el código no tiene nada de especial comparado con las versiones en otros lenguajes, sí es cierto que tuve problemas con Gdk.cairo_set_source_rgba, pues, como se ve, no sigue la estructura «normal» de llamadas como si fuera un objeto, sino que sigue el formato de C. Lo lógico sería hacer cr.cairo_set_source_rgba(…), pero no funciona. Y lo mismo con algunas otras.