Archivo de la categoría: Cacharreo

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

A ritmo de conga (3)

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.

Estaba empezando a preparar un programita para intentar dar órdenes a la aspiradora, y me encontré con que el emparejado no es tan sencillo como creía: simplemente entrando en el servidor web de la aspiradora y rellenando el formulario no era suficiente, así que tocaba seguir analizando.

Como ya comenté en la primera parte, cuando la aspiradora se pone en modo emparejamiento, lo que hace es convertirse en un punto de acceso WiFi con la IP 192.168.4.1, de modo que la app se conecta a dicha red y configura los parámetros de la WiFi real, además de algo más. Para conseguir esos datos, lo que hice fue ir de nuevo a mi Raspberry Pi configurada como punto de acceso, y modificar, primero, el fichero /etc/hostapd/hostapd.conf para dejarlo de manera que parezca una aspiradora. Para ello añadí un # a estas líneas para comentarlas y que no se procesasen (de manera que la red WiFi estaría abierta), y cambié el SSID por CongaGyro_123456:

country_code=ES
interface=wlan0
hw_mode=g
channel=1
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
#wpa=2
#wpa_key_mgmt=WPA-PSK
#wpa_pairwise=TKIP
rsn_pairwise=CCMP
ssid=CongaGyro_123456
#wpa_passphrase=xxxxxxxxx

A continuación, en /etc/dnsmasq.conf cambié los rangos de direcciones IP que se sirven para que sea 192.168.4.10 a 192.168.4.40, y asigné al gateway la IP 192.168.4.1.

Por último, edité el fichero /etc/dhcpcd.conf y en él asigné a la WLAN la dirección IP 192.168.4.1, la misma que tendría una aspiradora.

Tras esto reinicié la placa para que los nuevos valores tomasen efecto, entré por la ethernet, y lancé una instancia de netcat para que el puerto 80 estuviese abierto y me permitiese capturar todo. Al ser, además, un puerto por debajo de 1024, es necesario utilizar sudo:

sudo nc -l -p 80

Y tras esto, lo que hice fue ir a la app oficial, borrar mi aspiradora e indicarle que quería configurar una nueva. La app encontró la falsa aspiradora, se conectó a su WiFi, intentó configurarla mediante el puerto 80, y… ¡Bingo!

GET /robot/getRobotInfo.do?ssid=AAAAAAAA&pwd=BBBBBBBB&jDomain=bl-app-eu.robotbona.com&jPort=8082&sDomain=bl-im-eu.robotbona.com&sPort=20008&cleanSTime=5 HTTP/1.1
User-Agent: blapp
Accept: application/json
Host: 192.168.4.1
Connection: Keep-Alive
Accept-Encoding: gzip

Vemos que envía algo más que el SSID y la clave; en concreto añade dos dominios de Internet, que es a donde se conecta para la parte HTTP y la parte de su propio protocolo. Es posible que se pueda cambiar directamente por la IP de un servidor, pero de momento prefiero montar un DNS propio y que tire de él, para asegurarme de que todo funciona.

Para estar seguro de que con esto ya funcionaba, escribí un pequeño programa en python que se conectase al puerto 80, enviase exactamente eso, y luego esperase la respuesta (por completitud). Obviamente, al ser HTTP, cada nueva línea tiene que ser con \r\n, y el final se indica con \r\n\r\n. Y esta fue la respuesta:

HTTP/1.0 200 OK

{
  "result":"0",
  "msg":"OK",
  "version":"1.0",
  "data":{
    "deviceId":"zzzzzzzzzzzzzz",
    "deviceType":"1",
    "appKey":"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
    "authCode":"xxxxxx",
    "errorCode":"0xnnnnnnnn"
  }
}

Y esta vez sí, la aspiradora se conectó correctamente al servidor remoto y quedó lista para trabajar.

Parte 4

A ritmo de conga (2)

Actualizado: varios comandos de servidor a aspiradora terminaban con un LF. Añadidos.

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 sabía la estructura básica del protocolo de la aspiradora, decidí escribir un pequeño programa en Python que me extrajese la información de manera más clara, y así no tener que andar con Wireshark, que es bastante peñazo. Con él tengo las secuencias y el orden en que se transmiten disponibles de un vistazo. El código se puede bajar desde mi web.

Lo primero que hice fue apagar completamente la aspiradora para que se borrase todo lo que tuviese en memoria, y emparejarla de nuevo, a ver si cambiaba algo en el proceso. Tras comparar ambas, todo es igual excepto:

  • el campo nonce
  • el campo sign (aunque esto es, probablemente, porque actualicé el firmware)
  • el campo token
  • el campo authcode

A mayores, la aspiradora envió una petición HTTP extra a POST /baole-web/common/uploadLog.do HTTP/1.1 con información de un fallo, pero no parece nada importante.

Siguiendo con el análisis, decidí que tocaba empezar a enviar comandos, así que inicié la captura de paquetes y ordené a la aspiradora que comenzase a funcionar. Este fue el resultado (el campo targetId es, en realidad, un valor hexadecimal aparentemente aleatorio, pero único para cada aspiradora, así que lo anonimizo aquí. Es el mismo valor todas las veces que aparece). El número decimal es el instante de tiempo (en segundos) desde que empecé a registrar los datos, y el sentido del paquete se indica con s->a si es de servidor a aspiradora, a->s si es de aspiradora a servidor:

3.5093190670013428
s->a 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":"100"}
}\n

3.6597180366516113
a->s 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\"}"
  }
}

3.7990760803222656
a->s 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":"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\"}"
  }
}

3.883021116256714
s->a 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

4.672595024108887
a->s 14 00 00 00 | 00 01 c8 00 | 01 00 00 00 | 1b 00 00 00 | e7 03 00 00

4.731273174285889
s->a 14 00 00 00 | 11 01 c8 00 | 01 00 08 01 | 1b 00 00 00 | e7 03 00 00

6.818763017654419
a->s bd 01 00 00 | 18 00 00 00 | 01 00 00 00 | 1c 00 00 00 | 00 00 00 00
{
  "version":"1.0",
  "control":{
    "targetId":"0",
    "targetType":"6",
    "broadcast":"0"
  },"value":{
    "noteCmd":"102",
    "workState":"1",
    "workMode":"11",
    "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\"}"
  }
}

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

8.281097173690796
s->a d1 00 00 00 | fa 00 c8 00 | 00 00 09 01 | 1c 27 00 00 | 00 00 00 00
{
  "cmd":0,
  "control":{
    "authCode":"zzzzzz",
    "deviceIp":"192.168.18.3",
    "devicePort":"8888",
    "targetId":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType":"3"
  },
  "seq":0,
  "value":{"transitCmd":"131"}
}\n

8.425811052322388
a->s 54 01 00 00 | fa 00 00 00 | 01 00 00 00 | 1c 27 00 00 | 00 00 00 00
{
  "version":"1.0",
  "control":{
    "targetId":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType":"3",
    "broadcast":"0"
  },"value":{
    "transitCmd":"132",
    "doTime":"614",
    "clearArea":"0",
    "clearTime":"0",
    "clearModule":"11",
    "clearSign":"2020-06-24-01-31-41-2",
    "chargerPos":"-1,-1",
    "map":"AAAAAAAAZABk0vwAKtgACtgAKtPVAA==",
    "track":"AQABADIx"
  }
}

12.955074071884155
s->a d1 00 00 00 | fa 00 c8 00 | 00 00 09 01 | 1f 27 00 00 | 00 00 00 00
{
  "cmd":0,
  "control":{
    "authCode":"zzzzzz",
    "deviceIp":"192.168.18.3",
    "devicePort":"8888",
    "targetId":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType":"3"
  },
  "seq":0,
  "value":{"transitCmd":"131"}
}\n

13.06220006942749
a->s 5c 01 00 00 | fa 00 00 00 | 01 00 00 00 | 1f 27 00 00 | 00 00 00 00
{
  "version":"1.0",
  "control":{
    "targetId":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType":"3",
    "broadcast":"0"
  },"value":{
    "transitCmd":"132",
    "doTime":"619",
    "clearArea":"0",
    "clearTime":"5",
    "clearModule":"11",
    "clearSign":"2020-06-24-01-31-41-2",
    "chargerPos":"-1,-1",
    "map":"AAAAAAAAZABk0vwAKoDXAApA1wAqgNPUAA==",
    "track":"AQACADIxMzE="
  }
}

Bueeeeeeno… menuda parrafada. Sin embargo empezamos a sacar ya cosas en claro: para poner en marcha la aspiradora y que comience a limpiar, la clave parece estar en este bloque, que parece servir para enviar un comando específico: en este caso, el servidor envía un paquete con el comando 100, como se especifica en transitCmd.

s->a 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":"100"}
}\n

Otro detalle interesante es que siempre que la aspiradora envía un paquete, su séptimo byte es 00, pero si lo envía el servidor es C8. Esto también se cumplía en las comunicaciones anteriores, salvo en los paquetes ping, los que aparentemente sólo son necesarios para mantener la comunicación. A pesar de todo, esa parte aún va a requerir más investigación.

Una vez que la aspiradora está en marcha, el servidor envía repetidamente el comando 131, el cual parece que pide a la aspiradora que devuelva información sobre su posición y estado actual.

La darle a la opción de dejar de aspirar, los comandos que se intercambian son:

2.3617970943450928
s->a d1 00 00 00 | fa 00 c8 00 | 00 00 09 01 | 23 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":"102"}
}\n

2.4898791313171387
a->s dc 01 00 00 | fa 00 00 00 | 01 00 00 00 | 23 27 00 00 | 00 00 00 00
{
  "version": "1.0",
  "control": {
    "targetId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType": "3",
    "broadcast": "0"
  },
  "value": {
    "noteCmd": "102",
    "workState": "1",
    "workMode": "11",
    "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\"}"
  }
}

2.6665220260620117
a->s bc 01 00 00 | 18 00 00 00 | 01 00 00 00 | 1d 00 00 00 | 00 00 00 00
{
  "version": "1.0",
  "control": {
    "targetId": "0",
    "targetType": "6",
    "broadcast": "0"
  },
  "value": {
    "noteCmd": "102",
    "workState": "2",
    "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\"}"
  }
}

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

4.319652080535889
a->s 84 01 00 00 | 14 00 00 00 | 01 00 00 00 | 1e 00 00 00 | 00 00 00 00
{
  "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": "-1,-1",
    "extParam": "{\"fan\":\"2\",\"waterTank\":\"40\",\"mode\":\"0\"}",
    "map": "AAAAAAAAZABk0vwAaoDXAGpA1wBqgNcAqNL8AA==",
    "track": "AQAEADIxMzExMTEy"
  }
}

El comando para detenerse parece ser el 102, lo que es consistente con que el de iniciar sea el 100.

Por último, si pulsamos la opción de volver a la estación base, ésto es lo que sale:

0.530087947845459
s->a d1 00 00 00 | fa 00 c8 00 | 00 00 d6 4c | 25 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": "104"
  }
}\n

0.6716470718383789
a->s db 01 00 00 | fa 00 00 00 | 01 00 00 00 | 25 27 00 00 | 00 00 00 00
{
  "version": "1.0",
  "control": {
    "targetId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType": "3",
    "broadcast": "0"
  },
  "value": {
    "noteCmd": "102",
    "workState": "2",
    "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\"}"
  }
}

0.8249011039733887
a->s bc 01 00 00 | 18 00 00 00 | 01 00 00 00 | 20 00 00 00 | 00 00 00 00
{
  "version": "1.0",
  "control": {
    "targetId": "0",
    "targetType": "6",
    "broadcast": "0"
  },
  "value": {
    "noteCmd": "102",
    "workState": "4",
    "workMode": "0",
    "fan": "1",
    "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\"}"
  }
}

0.8965139389038086
s->a 3c 00 00 00 | 19 00 c8 00 | 01 00 00 00 | 20 00 00 00 | 01 00 00 00
{
  "msg":"OK",
  "result":0,
  "version":"1.0"
}\n

2.895132064819336
a->s 83 01 00 00 | 14 00 00 00 | 01 00 00 00 | 21 00 00 00 | 00 00 00 00
{
  "version": "1.0",
  "control": {
    "targetId": "0",
    "targetType": "6",
    "broadcast": "0"
  },
  "value": {
    "noteCmd": "101",
    "clearArea": "0",
    "clearTime": "15",
    "clearSign": "2020-06-24-01-31-41-2",
    "clearModule": "0",
    "isFinish": "0",
    "chargerPos": "49,49",
    "extParam": "{\"fan\":\"1\",\"waterTank\":\"40\",\"mode\":\"0\"}",
    "map": "AAAAAAAAZABk0vwAaoDXAGpA1wBqgNcAqNL8AA==",
    "track": "AQAEADIxMzExMTEy"
  }
}

2.9380459785461426
s->a d1 00 00 00 | fa 00 c8 00 | 00 00 09 01 | 27 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":"131"
  }
}\n

3.048809051513672
a->s 64 01 00 00 | fa 00 00 00 | 01 00 00 00 | 27 27 00 00 | 00 00 00 00
{
  "version": "1.0",
  "control": {
    "targetId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType": "3",
    "broadcast": "0"
  },
  "value": {
    "transitCmd": "132",
    "doTime": "643",
    "clearArea": "0",
    "clearTime": "15",
    "clearModule": "0",
    "clearSign": "2020-06-24-01-31-41-2",
    "chargerPos": "49,49",
    "map": "AAAAAAAAZABk0vwAaoDXAGpA1wBqgNcAqNL8AA==",
    "track": "AQAEADIxMzExMTEy"
  }
}

7.939983129501343
s->a d1 00 00 00 | fa 00 c8 00 | 00 00 09 01 | 2a 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": "131"
  }
}\n

8.04991602897644
a->s 6c 01 00 00 | fa 00 00 00 | 01 00 00 00 | 2a 27 00 00 | 00 00 00 00
{
  "version": "1.0",
  "control": {
    "targetId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType": "3",
    "broadcast": "0"
  },
  "value": {
    "transitCmd": "132",
    "doTime": "648",
    "clearArea": "0",
    "clearTime": "20",
    "clearModule": "0",
    "clearSign": "2020-06-24-01-31-41-2",
    "chargerPos": "50,50",
    "map": "AAAAAAAAZABk0vwAaoDXAGpA1wBqgNcAqNL8AA==",
    "track": "AQAGADIxMzExMTEyMjIyMQ=="
  }
}

12.952444076538086
s->a d1 00 00 00 | fa 00 c8 00 | 00 00 09 01 | 2c 27 00 00 | 00 00 00 00
{
  "cmd": 0,
  "control": {
    "authCode": "yyyy",
    "deviceIp": "192.168.18.3",
    "devicePort": "8888",
    "targetId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType": "3"
  },
  "seq": 0,
  "value": {
    "transitCmd": "131"
  }
}\n

13.123134136199951
a->s 70 01 00 00 | fa 00 00 00 | 01 00 00 00 | 2c 27 00 00 | 00 00 00 00
{
  "version": "1.0",
  "control": {
    "targetId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    "targetType": "3",
    "broadcast": "0"
  },
  "value": {
    "transitCmd": "132",
    "doTime": "653",
    "clearArea": "0",
    "clearTime": "25",
    "clearModule": "0",
    "clearSign": "2020-06-24-01-31-41-2",
    "chargerPos": "50,48",
    "map": "AAAAAAAAZABk0vwAaoDXAGpA1wBqgNcAqNgABdLjAA==",
    "track": "AQAHADIxMzExMTEyMjIyMTIz"
  }
}

13.785572052001953
a->s bc 01 00 00 | 18 00 00 00 | 01 00 00 00 | 22 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": "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\"}"
  }
}

13.849518060684204
s->a 3c 00 00 00 | 19 00 c8 00 | 01 00 00 00 | 22 00 00 00 | 01 00 00 00
{
  "msg":"OK",
  "result":0,
  "version":"1.0"
}\n

Y, efectivamente, el comando es el 104. ¿Serán pares todos los comandos?

Además, parece que cuando la aspiradora ha llegado al cargador, emite automáticamente una trama para indicarlo (la del instante de tiempo 13.785572052001953). También vemos que el servidor envía repetidamente el comando 131 durante el trayecto, lo que parece confirmar que, efectivamente, se utiliza para pedir la posición y estado de la aspiradora mientras se está moviendo.

Y con esto ya están analizados los tres comandos más importantes. En la próxima entrega intentaré enviar yo mis propios comandos haciéndome pasar por el servidor, y en la cuarta espero analizaré el resto de comandos: selección del modo de aspirado, potencia del motor, fregona y control manual.

Parte 3

A ritmo de conga (1)

Actualizado: varios comandos de servidor a aspiradora terminaban con un LF (o sea, \n). Añadidos.

Recientemente decidí comprarme un aspirador robótico, así que empecé a ver opciones. Es cierto que, siendo el primero, no quería gastarme mucho por si no me convencía, pero a la vez no quería tampoco comprar algo cutre que luego no funcionase. Así que al final, después de mucho pensar, decidí comprarme un Conga de Cecotec. Mi intención era comprar el más bajo de gama, pero estaba agotado, así que al final me pillé el Conga 1490, que tiene como característica diferenciadora que se puede controlar desde el móvil.

Y claro, me ponen un caramelito así, delante, y lo primero que se me pasó por la cabeza fue «no estaría mal meter las narices a ver cómo funciona».

Sin embargo, cuando me leí las condiciones de uso de la app y descubrí que envía tu IP, tu correo-e y los mapas que hace la aspiradora no sólo a Cecotec (que, a fin de cuentas, es una empresa española y está sujeta a las normas comunitarias de protección de datos), sino directamente al verdadero fabricante, que es chino (y te dicen claramente que él sí que no está sujeto a esas normas), la cosa empezó a mosquearme, y mis ganas de tocar dentro aumentaron bastante.

Pero no se vayan todavía, que aún hay más… resulta que se me ocurre ver qué permisos pide para funcionar, y me encuentro con que en el manifiesto pide, entre otras cosas:

  • hacer llamadas
  • acceso total a Internet
  • acceso al micrófono
  • acceso a la cámara y al flash
  • acceso de lectura y escritura a todo el almacenamiento del dispositivo
  • obtener datos del propietario
  • acceso a los logs del sistema
  • acceso a la lista de aplicaciones
  • arrancar automáticamente en el inicio del sistema

Esto ya me parecía el colmo: una aplicación china con acceso a TODO, y que encima se lanza automáticamente en el arranque… ni-de-co-ña.

Y sí, se lo que dirán algunos: la mitad de esos permisos (cámara, micrófono, llamadas y almacenamiento) no sólo se pueden desactivar en las propiedades de la aplicación, sino que, de intentar usarlos, el sistema operativo avisaría; y la otra mitad (acceso a datos del propietario y lista de aplicaciones) han sido eliminados en versiones recientes de Android. Pero pese a todo, es una cuestión de que no basta con ser bueno, también hay que parecerlo. Y esta app, por mucho de que en la práctica no haga nada malo, que dejen todo eso así queda, como mínimo, feo.

Pero es que, además, como veremos luego, la app y la aspiradora precisan de una conexión a internet para trabajar, lo que significa que si, por el motivo que sea, el fabricante decide que ya no le compensa tener encendido su servidor, MI aspiradora, por la que he pagado dinero, dejará de funcionar de la manera que me vendieron que funcionaría.

Así que toca arremangarse y empezar a trabajar.

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.

Lo primero que hice fue echar un vistazo al manual, y como cabía suponer, la aspiradora tiene WiFi y hay que emparejarla primero con el router. Para ello hay dos maneras, la automática y la manual. En ambos casos se comienza pulsando el botón de encendido de la aspiradora durante tres segundos, de manera que el piloto de la WiFi empieza a parpadear, y a continuación se mete la clave de la WiFi en la app (el SSID ya aparece, poniendo por defecto el mismo que usa el móvil).

Ahora, en modo automático, simplemente se deja que sea la propia app quien configure todo. Sin embargo, en modo manual hay que ir a Ajustes -> WiFi en el móvil y escoger una red con el identificador CongaGyro_XXXXXX, pulsar el botón de configurar, y volver a la WiFi normal. Efectivamente: al ponerse en modo emparejamiento, la aspiradora se convierte en un punto de acceso sin clave.

El primer paso, por tanto, era obvio: poner la aspiradora en modo emparejamiento, conectarse a esa WiFi desde el portátil (en mi caso me dio la IP 192.168.4.2), y hacer un escaneo de puertos con nmap:

nmap -p 1-49151 192.168.4.1

Y tal y como cabía esperar, el puerto 80 está abierto, y al entrar desde un navegador aparece esta página:

Tras escribir el SSID y la clave de mi WiFi, devuelve una página con un OK y el punto de acceso desaparece. Una simple comprobación con nmap nos devuelve la nueva IP de la aspiradora, ahora sí en la WiFi de verdad.

nmap -sP 192.168.18.0/24

Por supuesto, cada uno tiene que cambiar la IP por el valor de las IPs internas de su red. Una vez encontrada la IP, un nuevo escaneo de puertos me dejó descolocado: no había absolutamente ninguno, todos estaban cerrados a cal y canto.

Decidí que poco más podría hacer sin instalar la aplicación y esnifar los datos que intercambiaba con el aspirador, pero no me hacía ninguna gracia meterla en mi móvil, así que cogí una vieja tablet que no usaba para nada, la formateé, la registré con una cuenta de correo creada ex profeso, y me fui a la Play Store a instalar la app

Y no estaba.

Probé con todo tipo de combinaciones, pero aunque estaban algunas otras apps de Cecotec, la que había visto para la Conga 1490 y 1590 no aparecía por ninguna parte. Curiosamente fue lo mismo que me ocurrió cuando probé con un Android X86 corriendo en una máquina virtual… Mi conclusión es que, dado que en el manifiesto de la app pide permitir hacer llamadas, la Play Store sólo deja instalarla en dispositivos con tarjeta SIM.

Sin embargo, si nos bajamos el APK desde otra fuente, lo copiamos a la tablet y lo instalamos (tras permitir la instalación desde fuentes no confiables, claro) la aplicación funciona perfectamente.

Con ella ya instalada, llega la segunda parte: configurar un punto de acceso que nos permita esnifar todo el tráfico. El problema surge cuando nos damos cuenta de que es muy difícil hacer eso en WiFi, así que mi solución fue usar una Raspberry Pi configurada como punto de acceso, y correr en ella tcpdump para capturar todo el tráfico que pasase.

Para configurar una Raspberry Pi como punto de acceso basta con seguir estas instrucciones:

https://www.raspberrypi.org/documentation/configuration/wireless/access-point-routed.md

Yo utilicé la res 192.168.18.X para la WiFi, y así asegurarme de que no haya interferencias con nada más. Luego, para capturar todo el tráfico, uso el comando:

sudo nohup tcpdump -ni wlan0 '(not dst 192.168.18.4) and (not src 192.168.18.4)' -s0 -w datos.pcap

Siendo 192.168.18.4 la dirección IP de mi ordenador de sobremesa, para que no capture los paquetes que se intercambian cuando entro por SSH, y así tener una captura limpia. El parámetro w almacena los paquetes en bruto en el fichero datos.pcap, lo que nos permite luego abrirlo en, por ejemplo, WireShark, para analizarlos con más detenimiento.

Y con esto está todo listo para empezar, así que puse en marcha la captura de paquetes, y en la tablet lancé la aplicación, rellené los campos con una dirección de correo nueva, creada para la ocasión, emparejé la aspiradora, y lancé un escaneo de puertos… ¡y ahora la aspiradora tenía uno abierto, el 8888!

Buena cosa, pues, pero ahora tocaba parar la captura y empezar a analizar qué habían hecho la aspiradora y la app.

Una vez copiado el fichero con el volcado, procedí a abrirlo con el Wireshark. Lo primero que hace la aspiradora, como cabe esperar, es obtener una IP, pero luego resulta que lo siguiente que hace es conectarse a Ibl-app-eu.robotbona.com, y enviar una petición POST a baole-web/common/sumbitClearTime.do con un formulario con el siguiente contenido (en realidad va en formato URLEncode:

appKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
deviceId=yyyyyyyyyyyyyyy
deviceType=1
clearTime=0

Donde xxxxxxx e yyyyyyy son unas ristras de números hexadecimales que, probablemente, identifiquen a mi aspiradora. La respuesta es un OK con el siguiente texto:

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

Que resulta ser un bloque en formato chunked con un resultado en formato JSON. A continuación otra conexión al mismo servidor, pero esta vez pidiendo:

appKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
authCode=zzzzz
deviceId=yyyyyyyyyyyyyy
deviceType=1
funDefine=11101
nonce_str=AAAAA
version={"wifi":"1.0.48","mcu":"3.9.1714(513)"}
sign=SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS

Una vez más lo he anonimizado just in case. La respuesta es la misma que antes:

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

Hasta aquí la aspiradora se ha conectado al puerto 80 del destino, lo que es consistente con el uso de REST. Sin embargo, ahora la aspiradora se conecta al puerto 20008 del destino, y comienza un intercambio de mensajes diferente. La siguiente petición desde la aspiradora es un bloque que comienza con una secuencia de 20 bytes binarios, seguidos por 349 bytes en ASCII en formato JSON (el JSON, en realidad, se transmite en una única línea y sin formato, pero lo he embellecido para mejorar la lectura. Si en algún punto incluyese un LF, incluiría el símbolo \n):

71 01 00 00 | 10 00 00 00 | 01 00 00 00 | 02 00 00 00 | 00 00 00 00
{
  "version":"1.0",
  "control":{
    "targetId":"0",
    "targetType":"6",
    "broadcast":"0"
  },
  "value":{
    "token":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "deviceId":"yyyyyyyyyyyyyy",
    "appKey":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "deviceType":"1",
    "authCode":"zzzzz",
    "deviceIp":"192.168.18.3",
    "devicePort":"8888",
    "firmwareVer":"{\"wifi\":\"1.0.48\",\"mcu\":\"3.9.1714(513)\"}"
  }
}

Aquí ya empieza a haber cosas interesantes: la aspiradora comunica al servidor cual es la IP interna y el puerto que ha abierto para comunicarse. Estos datos son importantes para la app. Se repiten los campos deviceId, appKey y authCode de mensajes anteriores, pero ahora se añade un token.

La respuesta también es complicada. En este caso es de 99 bytes, y comienza también con una ristra de 20 valores binarios, seguido de 79 bytes con un JSON normal:

63 00 00 00 | 11 00 c8 00 | 01 00 00 00 | 02 00 00 00 | 00 00 00 00
{
   "msg":"login succeed",
   "result":0,
   "version":"1.0",
   "time":"2020-06-21-04-59-39"
}

En el primer mensaje, los dos primeros bytes en formato little endian forman el número 369, que coincide con la suma de los 20 bytes binarios y los 349 bytes de JSON. En el segundo mensaje, los dos primeros bytes coincide con 99 en decimal, por lo que parece que, como mínimo, los dos primeros bytes indican el tamaño de la respuesta, probablemente para poder reservar la cantidad de memoria que necesite en el momento, y no tener que reservar de más, o ampliando bloques. ¿Qué significan el resto de bytes? De momento no tenemos información suficiente para saberlo.

Siguiente envío desde la aspiradora 444 bytes:

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

Y otra vez tenemos una mezcla binaria y ASCII, en este caso de 60 bytes:

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

Esto nos confirma que, efectivamente, lo primero que recibimos es el tamaño de la respuesta completa.

Editado: faltaba un \n al final del JSON.

Ahora la aspiradora envía sólo 20 bytes, lo que es sólo la cabecera:

14 00 00 00 | 00 01 c8 00 | 01 00 00 00 | 04 00 00 00 | e7 03 00 00

Y la respuesta del servidor es

14 00 00 00 | 11 01 c8 00 | 01 00 08 01 | 04 00 00 00 | e7 03 00 00

En este punto, la app se conecta a la aspiradora y empieza a intercambiar datos con este mismo formato, pero de momento vamos a dejar esa parte de lado, pues esta conexión con el servidor remoto sigue estando activa. De hecho, 30 segundos después del envío anterior, la aspiradora envía de nuevo otra secuencia, en concreto:

14 00 00 00 | 00 01 c8 00 | 01 00 00 00 | 05 00 00 00 | e7 03 00 00

Y la respuesta del servidor es

14 00 00 00 | 11 01 c8 00 | 01 00 08 01 | 05 00 00 00 | e7 03 00 00

Otra vez inactividad hasta que 30 segundos después volvemos a tener otro paquete desde la aspiradora:

14 00 00 00 | 00 01 c8 00 | 01 00 00 00 | 06 00 00 00 | e7 03 00 00

Y la respuesta del servidor es

14 00 00 00 | 11 01 c8 00 | 01 00 08 01 | 06 00 00 00 | e7 03 00 00

Lo interesante es que empieza a haber un patrón aquí. Si asumimos que son cinco números de cuatro bytes cada uno, el cuarto número es un número de secuencia: en efecto, comienza en 2 en la primera petición, y su respuesta tiene el 2 también. La siguiente petición tiene el valor 3, y su respuesta también. Luego viene el 4, y otra vez la respuesta, el 5 y otra vez la respuesta, el 6 y su respuesta… Además, el resto de los números de las peticiones y respuestas 4, 5 y 6 son iguales, lo que hace suponer que se utilizan para mantener la conexión activa. Por otro lado, en las peticiones, el quinto byte tiene el bit 0 a cero, y en las respuestas están a uno. En el sexto byte, además, el bit 0 está a cero en peticiones con datos, y a 1 en las que son sólo para mantener la conexión.

Veamos ahora qué pasa con la app en sí. Como ya dije, se conecta al puerto 8888, pero lo raro es que establece dos conexiones en vez de una. ¿Por qué? Ni idea. Pero veamos con calma qué hace.

En la primera conexión tenemos este intercambio, antes de que la aspiradora la cierre (t es tablet, a es aspiradora):

t->a: 14 00 00 00 | 00 01 c8 00 | 00 00 01 00 | 1f 27 00 00 | 00 00 00 00
a->t: 14 00 00 00 | 11 01 c8 00 | 01 00 00 00 | 1f 27 00 00 | e7 03 00 00
t->a: 14 00 00 00 | 00 01 c8 00 | 00 00 01 00 | 1f 27 00 00 | 00 00 00 00

En la segunda conexión se intercambian estos datos:

t->a: 14 00 00 00 | 00 01 c8 00 | 00 00 01 00 | 1f 27 00 00 | 00 00 00 00
a->t: 14 00 00 00 | 11 01 c8 00 | 01 00 00 00 | 1f 27 00 00 | e7 03 00 00
t->a: 14 00 00 00 | 00 01 c8 00 | 00 00 01 00 | 1f 27 00 00 | 00 00 00 00
a->t: 14 00 00 00 | 11 01 c8 00 | 01 00 00 00 | 1f 27 00 00 | e7 03 00 00
t->a: 14 00 00 00 | 00 01 c8 00 | 00 00 01 00 | 1f 27 00 00 | 00 00 00 00
a->t: 14 00 00 00 | 11 01 c8 00 | 01 00 00 00 | 1f 27 00 00 | e7 03 00 00
t->a: 14 00 00 00 | 00 01 c8 00 | 00 00 01 00 | 1f 27 00 00 | 00 00 00 00
a->t: 14 00 00 00 | 11 01 c8 00 | 01 00 00 00 | 1f 27 00 00 | e7 03 00 00
t->a: 14 00 00 00 | 00 01 c8 00 | 00 00 01 00 | 1f 27 00 00 | 00 00 00 00

O sea, más o menos lo mismo. De aquí no puedo sacar mucha información.

Hasta aquí la primera parte. Ahora toca empezar a hacer pruebas para intentar descubrir cómo dar órdenes a la aspiradora.

Parte 2

1984: mi pequeño homenaje a un paper icónico del CGI

En 1984, hace ya la friolera de 35 años, un grupo de investigadores de la división de gráficos por ordenador de Lucasfilm (lo que un par de años después pasaría a llamarse Píxar) publicó un paper donde presentó la solución definitiva a muchos de los problemas del momento de los gráficos por ordenador orientados al cine y al entretenimiento. Me refiero al Distributed Ray Tracing. Esta técnica permitía añadir a los gráficos por ordenador hechos mediante Ray Tracing elementos fotorrealistas tales como motion blur, profundidad de campo, sombras con penumbras, antialiasing, y más, y todo ello sin añadir prácticamente carga computacional extra. Esa era su gran novedad.

Es cierto que el Ray Tracing es una técnica tan costosa a nivel de procesador y, sobre todo, de memoria, que, durante décadas, Renderman, el render desarrollado por Píxar y utilizado en la inmensa mayoría de escenas de películas hechas por ordenador, no la utilizaba, sino que se basaba en la técnica REYES (Renders Everything You Ever Saw). Era la única manera de poder hacer gráficos fotorrealistas en los equipos de los 80, los 90 y buena parte de los 2000. Sin embargo, algunas de las técnicas desarrolladas en el paper se pudieron adaptar a dicho algoritmo, específicamente el motion blur y la profundidad de campo. Hoy en día, en cambio, sí que se utiliza Ray Tracing de manera rutinaria, basado en mejoras de las técnicas presentadas en este paper. Así trabajan hoy en día Ray Tracers comerciales como Arnold, o el propio Renderman a partir de su versión 19.

Sin embargo, además de todo esto, al final de dicho paper aparece una imagen que muestra las capacidades de los algoritmos descritos en él, y que ya se puede considerar icónica. Dicha imagen se titula 1984 y muestra una mesa de billar con cinco bolas, las cuales exhiben los efectos de motion blur, sombras suaves, reflectividad (lo que permite ver la ventana de la sala y a una persona sosteniendo un taco)…

¿Y a qué viene toda esta chapa? Pues a que he querido hacer un pequeño homenaje a dicha publicación y a los genios detrás de ella programando un pequeño Ray Tracer propio con el que reproducir dicha imagen. Y éste es el resultado:

Mi versión/homenaje. Pulsa sobre ella para ampliarla.

Al igual que en la imagen original, todas las bolas son reflectantes (lo que permite apreciar el entorno de la sala), y cuatro de ellas tienen motion blur: algunas (como la blanca y la bola 9) tienen un motion blur sencillo, y las otras dos, la 8 y la 4, uno más complejo, al estar detenidas durante parte del tiempo de apertura del obturador y moverse sólo durante parte del tiempo de exposición. Por supuesto, no cabe esperar una reproducción fidedigna (¡Soy programador, Jim, no artista!), pero lo importante para mí no es tanto la imagen en sí, sino el hecho de que he sido capaz de programar un Ray Tracer desde cero. Desde que leí el paper original quedé prendado de la elegancia del algoritmo, y tenía ganas de intentar implementarlo.

Sin embargo, lo divertido (relativamente) es que acabé escribiendo no uno, sino dos Ray Tracers: cuando empecé con este proyecto, hace un par de semanas, aún no tenía muy claro el nivel de complejidad que me iba a encontrar, así que decidí no complicarme la vida más de lo estrictamente necesario y opté por utilizar Python para escribirlo, junto con numpy para toda la parte de vectores y matrices que necesitaba. Primero construí un Ray Caster capaz de mostrar esferas y planos. Luego le añadí soporte de mapeado de texturas. A continuación antialiasing y soporte de luces. Llegado a este punto añadí soporte de motion blur y empecé a preparar ya la escena de la sala de billar en lugar de las imágenes de demostración con una Cornell Box.

El resultado es el de ahí arriba. Sin embargo, tenía un problema serio: es desesperadamente lento. Es cierto que, como dije antes, la técnica de Ray Tracing es muy costosa computacionalmente, pero es que hablamos de ¡18 horas para esa imagen a 1920×1080! ¡Y eso que ya había hecho unas cuantas optimizaciones que me permitieron reducir los tiempos a, aproximadamente, dos tercios de los originales, además de utilizar todos los núcleos de mi ordenador! Es más o menos lo que habría tardado a principios de los 90 con PovRay y un 8086.

Fue por esto que, aunque el objetivo original ya estaba cumplido, tomé la decisión de portar el código a C++ para ver cuanto podía arañar. Quería saber si el problema estaba en Python o en mi código. Aunque dicho así puede sonar a locura, lo cierto es que el código de Python era bastante sencillo (actualmente son un total de 1415 líneas de código, que, tras quitar líneas en blanco y comentarios, se quedan en 899), con lo que era factible coger cada uno de los ficheros y traducirlo de un lenguaje a otro. Sólo tuve que escribir un par de clases para trabajar con vectores y matrices y alguna cosa más, lo que no fue especialmente difícil. Es cierto que probablemente podría haber usado cualquiera de las alternativas que seguro que hay por ahí ya escritas, pero dado que sólo tengo que trabajar con matrices y vectores de tamaño fijo, y sólo necesito un puñado de operaciones muy concretas, era más trabajo aprender a usarlas que escribirlo yo. Y encima es probable que me diese más rendimiento (repito: tamaño fijo).

El resultado fue, en una sola palabra: pasmoso. De las más de 18 horas pasé a 398 segundos. Repito: menos de siete minutos para la misma imagen: 1920×1080 y supersampling de 8×8.

Sí es cierto que me encontré con un problema algo raro: en Python utilicé fork() para poder aprovechar todos mis núcleos porque, como es de sobra conocido, el GIL no permite que varios threads se ejecuten de manera concurrente. Esto complicó bastante el código, pues tuve que usar pipes para que el programa principal fuese repartiendo nuevas líneas a cada proceso hijo a medida que iban terminando y quedando libres, y también para que éstos enviasen las líneas ya renderizadas al padre para que las uniese en una sola imagen. Para la versión en C++ no quería complicarme tanto, así que decidí utilizar pthreads desde el principio, de manera que todos los hijos accediesen a la misma memoria para ir depositando sus líneas en el punto correcto sin tener que enviarlas mediante IPC, y autoasignándose la siguiente línea mediante un semáforo. Sin embargo, algo raro ocurría: tardaba más que cuando utilizaba un único proceso. Si no fuese porque top indicaba una carga de procesador del 400%, y que el procesador indicaba que había seis threads de mi programa, habría asegurado que estaba utilizando una biblioteca de threads en espacio de usuario, sin threads reales.

Después de muchas pruebas, de quemarme las pestañas viendo el código, y buceando en Stack Overflow y otros sitios, no conseguí descubrir qué ocurría, así que decidí utilizar también fork() en C++, pero añadiendo memoria compartida para simplificar la comunicación y la complejidad al máximo, y ahí sí que conseguí la ganancia de rendimiento esperada; esto es, multiplicar la velocidad por (prácticamente) el número de núcleos del ordenador.

Y esto es más o menos todo. Aquellos que quieran curiosear un poco en mi código, lo tienen disponible en mi repositorio git:

https://gitlab.com/rastersoft/1984

Un poco de modding

Hace tiempo me compré una caja Elite 110 de Cooler Master. Esta caja tiene la ventaja de ser muy pequeña y compacta gracias a que no tiene hueco para lector de CD/DVD, por lo que me gustaba mucho. Su inconveniente, sin embargo, es que sólo permite placas madre mini-ITX; esto es, de un máximo de 17x17cm, aunque para mí tampoco era un problema, pues mi placa madre era de ese formato. Sin embargo, recientemente se me estropeó, y ahí empezaron mis problemas, porque por más que buscaba no encontraba ninguna placa decente para socket Intel LGA 1150 en formato mini-ITX. Sí las había en formato micro-ATX, pero obviamente eran demasiado grandes y no entraban, o bien un par de modelos chinos a la venta en Amazon que me inspiraban poca o ninguna confianza.

Sin embargo, de pura casualidad, encontré que en una tienda de aquí vendían la Gigabyte GA-B85M-DS3H, que medía 22,6 x 19,3 centímetros. Armado con el metro vi que sí entraba, justita pero entraba, así que la compré. Cual no sería mi decepción cuando fui a montarla, al descubrir que no entraba por unos míseros tres milímetros.

Afortunadamente no tropezaba con el borde de la caja, sino con un tabique interno que permite montar discos duros o bien dos ventiladores extra, así que, tras verlo con calma, comprobé que era posible recortar parte del tabique de manera que la placa entrase. Y dicho y hecho.

Lo primero fue desmontar el tabique. Para ello utilicé el taladro y la broca de 3 milímetros para romper los siete remaches que lo sujetaban (tres delante, dos debajo y dos detrás). Una vez retirado, usé la dremel para recortar la parte inferior. Primero corté el nervio central, como se ve en esta foto (pulsa sobre las imágenes para ampliarlas):

Luego corté por los laterales. En la parte trasera corté justo por debajo del agujero del remache, pero en la delantera corté primero en vertical y luego de manera perpendicular, para así dejar más sitio para la placa. En la siguiente foto se ve, marcado en rojo, la dirección de corte para cada lado:

Una vez hecho esto limamos la pieza con una lima de metal para quitar asperezas que puedan dañar los cables, y limpiamos a conciencia la pieza para eliminar restos de limaduras de aluminio que puedan causar cortocircuitos (yo suelo lavarla un poco y luego secarla cuidadosamente; por hacerlo una vez no es un problema).

Y llega el momento de volver a colocarla en su sitio: para ello colocamos primero los cinco remaches que quedan, pero sin apretarlos aún. Una vez introducidos, les damos un primer apretón a cada uno para asegurarnos de que la pieza se vaya situando en su lugar, y sólo entonces damos el segundo apretón, el que romperá el vástago interno del remache. Este es el resultado:

Ahora ya tenemos espacio suficiente para montar la placa sin que tropiece con nada, y teniendo incluso unos milímetros de espacio extra:

Sin embargo, ya que había desmontado la caja, decidí intentar mejorarla un poco. Por un lado, echaba de menos disponer de un conector eSATA, y por otro, en varias ocasiones me encontré con que necesitaba leer tarjetas SD y similares. Aunque disponía de un lector de tarjetas para montar en una bahía de 3 1/2 pulgadas, era bastante largo; afortunadamente, por dentro estaba prácticamente hueco, así que cogí la dremel y recorté su contenedor hasta el tamaño mínimo, y luego lo sujeté a la parte superior del frontal así:

Fijarlo no fue algo inmediato: el tornillo izquierdo fue relativamente sencillo, pues coincidía con un agujero del frontal y era suficiente con añadir una arandela, pero el tornillo derecho caía justo en la abertura grande que se ve junto al lector. La solución consistió en tomar un trozo de alambre de un clip y doblarlo en forma de gancho:

Este gancho lo metí en el tornillo derecho (también con una arandela) y luego lo doblé por uno de los agujeros del ventilador, situados debajo de la abertura grande, tal y como se ve en estas fotos:

Luego sólo fue necesario hacer un recorte con la dremel en la parte superior del frontal para que sobresaliese el lector.

El conector eSATA fue lo más sencillo de implementar: sólo tuve que coger el taladro y abrir un agujero rectangular del tamaño adecuado en la parte superior derecha del frontal, además de dos agujeros para los tornillos:

Por último, decidí montar el ventilador frontal en este lado del aluminio en lugar de hacerlo en la zona útil de la caja, para así ganar un par de centímetros extra. Por desgracia casi no había sitio, entre el lector de tarjetas y el interruptor de encendido; sin embargo, recortando una de las esquinas conseguí que entrase:

Y con esto ya está terminada: una caja compacta pero con la que no renuncio a nada.

Cargando mis cascos (y 2)

Hace ya dos años modifiqué unos cascos BlueTooth para que sólo con colgarlos de un soporte se cargase su batería. Aunque funcionó bien, en este tiempo hice algunos cambios extra, y creo que ha llegado la hora de publicarlos.

Para empezar, en el artículo anterior comentaba que había utilizado malla de desoldar para hacer los contactos. Aunque funcionó bien durante unos cuantos meses, lo cierto es que el pegamento de la cinta de doble cara fue permeando a través de la malla de cobre y acabó por dar problemas de conductividad, por eso al final opté por comprar cinta adhesiva de cobre y crear con ella los nuevos contactos (pulsa sobre las imágenes para verlas en grande).

Lo primero fue pegar la cinta por la parte de arriba de la diadema, como se ve en la foto, dejando pegada sólo aproximadamente un tercio. Luego, con unas tijeras, procedí a dar cortes en la parte que quedaba colgando, cada dos centímetros aproximadamente. De esta manera pude doblarlo sobre el lateral y garantizar que quedase bien pegado y, a la vez, con continuidad eléctrica sobre toda la superficie. Este fue el resultado:

El último paso en los cascos fue soldar los cables que vienen del puente de diodos a los dos contactos (véase la entrada anterior donde explico las modificaciones a nivel eléctrico), y proteger todo con un par de vueltas de cinta aislante en cada lado.

El siguiente punto era el soporte. En el artículo anterior mostré la chapuza que hice con una alcayata y lengüetas sacadas de una pila de petaca, pero llevaba tiempo con ganas de hacer algo más serio, así que decidí darle uso a las impresoras 3D de A Industriosa para construir un soporte en condiciones. Para ello, primero hice el diseño con FreeCAD, que tiene esta pinta y cuyo fichero podéis descargar desde este enlace.

Una vez impresa llegó la cuestión de como hacer las lengüetas para los contactos. Para ello decidí hacerlas de plástico flexible, sacado de una tapa de tornillos, y luego recubrirlas con cinta adhesiva de cobre. En la foto siguiente se ven las dos lengüetas.

Tras cortarlas, las pegué en el soporte utilizando cola térmica, quedando como en la siguiente foto.

El siguiente paso fue recubrirlas con la cinta adhesiva de cobre y soldar los cables del transformador de alimentación. Sin embargo, aunque quedó bien, es cierto que habría sido mejor pegar primero la cinta adhesiva y soldar los cables, y sólo después pegar las lengüetas con la cola térmica, pues cuando apliqué el soldador me encontré con que se me despegaban.

Tras hacer todo esto, éste es el resultado del soporte, listo para acoger en su seno a los cascos. Vemos que está fijado a la balda con un tornillo, y que los cables de alimentación están soldados en la parte superior, dejando toda la superficie de las lengüetas para que haga contacto con los electrodos de los cascos.

Y este es el resultado final: un soporte con mucha mejor presencia y calidad, y que me garantiza que mis cascos siempre estarán adecuadamente cargados cada vez que los necesite.