Archivo de la categoría: Cacharreo

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.


Más Gentoo para MipsEL

Estoy actualizando la distribución de Gentoo para webtv y, como no podía ser de otra manera, hay problemillas. El último ha sido con busybox. Para poder compilarla hay que añadir, además de las opciones que indico en Generando Gentoo para WebTV, hay que añadir las siguientes:

busybox_config_option n NANDWRITE
busybox_config_option n NANDDUMP
busybox_config_option n FLASH_ERASEALL
busybox_config_option n FLASHCP
busybox_config_option n BLKDISCARD

Por otro lado, la variable USE debe contener:

USE="${ARCH} -pam -fortran -sanitize -X -iptables -hardened -seccomp -ipv6 -systemd -mdev internal-glib -caps -gtk -qt -t -boehm-gc -nls -filecaps"

El cubo III

Cuando actualicé mi PC hace unos años, opté por deshacerme del viejo Cubo II y utilizar una caja comercial cúbica. Luego volví a actualizar el ordenador y pasé a una caja clásica. Finalmente, hace unos meses, decidí que era hora de darle un poco a la carpintería, así que decidí construir una caja nueva.

Tras darle varias vueltas, sopesando posibles materiales, de pronto caí en la cuenta de que podía aprovechar la vieja caja cúbica. Esta caja era bastante grande, así que podía intentar reducirla a un tamaño más manejable (pulsar sobre las imágenes para verlas más grandes).

caja_pequena_2 caja_pequena_1

Esta es la caja original. No es realmente muy grande, pero aún así, dado que está pensada para contener placas MicroATX, dos unidades de 5 1/4 y una de 3 1/2 externas, y hasta dos discos duros internos, es obvio que sobra mucho espacio. Sobre todo cuando yo ya no uso el DVD para nada.

Lo primero que hice fue desmontarla completamente y dejar sólo el armazón.

caja_pequena_3

Vemos que hay dos piezas que van desde el frontal hasta la parte posterior y que, además de dar más solidez a la caja, permiten sostener las unidades externas; sobre una de ellas, además, se apoyan los discos duros y la fuente de alimentación. También hay una barra metálica en el otro lado. Las piezas están sujetas con remaches, que quité reventándolos con el taladro. La barra está atornillada, así que no tuvo ninguna dificultad.

caja_pequena_4

El siguiente paso consistió en cortar el chasis para acortarlo. Decidí reducirlo a lo mínimo que me permitiese la base donde se atornilla la placa madre (que es una pieza independiente). También recorté las dos mitades en altura, teniendo en cuenta el tamaño de la fuente de alimentación y la mínima altura a la que la podría poner.caja_pequena_5 caja_pequena_6

Una vez hecho esto, uní ambas mitades con remaches, consiguiendo así el chasis del tamaño que quería.

caja_pequena_7

A continuación, recorté la barra para ajustarla al tamaño, la limé bien para quitarle asperezas, y le hice un agujero en el lado recortado para poder fijarla con un tornillo en los mismos agujeros del chasis donde estaba originalmente.

caja_pequena_8 caja_pequena_9

Ahora tenía que añadir la pieza que va desde el frontal hasta la parte posterior, para poder apoyar la fuente de alimentación. Para ello eliminé primero los soportes de las unidades externas (usando el taladro, como en la primera foto), y la fijé boca arriba para poder recortar cómodamente los laterales con la dremel.

caja_pequena_11

Hecho esto, la fijé boca abajo sobre un listón y recorté la parte central, dejando unos dos centímetros extra de manera que dispusiese de una lengüeta, necesaria para fijar la pieza en el otro lado de la caja.

caja_pequena_12 caja_pequena_13

Doblé la lengüeta de manera que quedase perpendicular a la parte superior. Para que quedase lo mejor posible puse la pieza boca abajo de nuevo y la fijé con un listón, cuyo borde estaba situado justo en el punto de doblez. Luego sólo tuve que ir golpeando poco a poco con el martillo hasta conseguir dejarla exactamente como deseaba. El listón garantizó que sólo se doblará por donde me interesaba.

caja_pequena_14

Hecho esto, hice dos agujeros en los puntos adecuados y sujeté la pieza con unos remaches. Con esto terminé el chasis.

caja_pequena_15

Ahora le tocaba el turno a la fuente de alimentación. La coloqué sobre la pieza y recorté la zona de la rejilla de la parte posterior para dejar libres los conectores, además de hacer algún agujero extra para los tornillos.

caja_pequena_16

Llegó el turno de construir el frontal. Al principio había probado a recortar y recomponer el original, pero quedaba fatal, así que decidí hacer yo mismo uno nuevo. Y ya puestos, decidí darle algo de estilo, así que lo hice directamente en madera. Lo primero fue conseguir un trozo de tablero del tamaño deseado para el frontal y marcar en él qué zonas se van a recortar para colocar los conectores delanteros. En este caso dejé hueco para un lector de tarjetas, los conectores de audio, dos USB 2.0, dos USB 3.0 y dos eSATA.

caja_pequena_17

Procuré que la veta de la madera quedase en la misma dirección que los cortes más largos, pues simplifica el trabajo. A continuación fijé un listón de madera bien recto en cada una de las líneas a recortar, y usé un cincel de madera para recortar hasta una profundidad de unos cinco milímetros. El cincel lo iba apoyando en el listón para asegurarme de que el corte fuese lo más recto posible.

caja_pequena_18 caja_pequena_19

Una vez hechos todos los cortes, le di la vuelta a la tabla y usé una fresa para ir rebajando (poco a poco, unos milímetros de cada vez) por el lado opuesto las zonas de cada conector, hasta quedar a unos cuatro milímetros del frontal. Al hacerlo, los cortes que hice antes con el cincel hicieron que la pieza interna de los agujeros se soltase, quedando así libres.

caja_pequena_20

El siguiente paso fue lijar a conciencia la superficie, limar los bordes de los agujeros para dejarlos regulares y lisos, recortar los bordes del tablero con una fresa para darles un acabado redondeado, y barnizarla con tres capas de barniz. Este es el resultado definitivo, junto con los tres intentos fallidos anteriores.

caja_pequena_21

Luego ya sólo hubo que añadir el botón de encendido, los LEDs y los conectores por el lado trasero.

caja_pequena_22 caja_pequena_23

El siguiente paso fue el soporte del disco duro. Partí del mismo que tenía la caja vieja, pero lo recorté (el original tenía espacio para dos discos duros apilados, pero quedaba muy pegado a la tarjeta gráfica).

caja_pequena_28 caja_pequena_29

A continuación enderecé y volví a doblar la lengüeta superior para «colgar» el soporte de la barra que cruza la caja. Para ello utilicé un destornillador con el mismo diámetro que la barra, y el martillo. También recorté el lateral izquierdo para hacer una pestaña que encajase en la parte izquierda, para así sujetar el soporte a la caja con un remache (en la segunda foto, es la pestaña situada encima de las aberturas de las tarjetas de expansión, a la izquierda).

caja_pequena_30 caja_pequena_31

Por último, enderecé la pestaña de la derecha y le fijé un soporte para discos de 2 1/2 pulgadas, porque quería añadir un disco SSD a mi equipo.

caja_pequena_32

Y así quedaron ambos discos, ya montados y conectados.

caja_pequena_33

El último paso fue preparar la tapa y las paredes laterales. La tapa la recorté un centímetro más larga de lo necesario, y luego coloqué una tabla para doblarla en ángulo con el martillo. Esa zona quedó hacia atrás.

caja_pequena_24

Por último, las paredes. Comencé colocando las láminas originales en su sitio para marcar los dos cortes necesarios.

caja_pequena_25

Luego corté dejando un centímetro de más tanto por arriba como por detrás. La esquina común a ambos cortes la corté en chaflán. A continuación, usando una vez más un listón sujeto con dos sargentas y el martillo, procedí a doblar 180 grados el trozo que sobra. Al haber cortado en chaflán la esquina común, las dos lengüetas no se solaparon (como se puede ver en la tercera fotografía).

caja_pequena_26 caja_pequena_27

caja_pequena_35

Y este es el resultado final, una vez ensamblado todo y, por supuesto, darle una mano de pintura negra en spray: una caja que ocupa, aproximadamente, 2/3 del volumen de la caja original.

caja_pequena_34