martes, 4 de diciembre de 2012

Hackeando termómetro inalámbrico





En este proyecto vamos a buscar la manera de recibir la información transmitida por un termómetro inalámbrico desde Arduino, para poder procesar la información desde PC.

Yo dispongo de una estación meteorológica barata de las que venden en el supermercado por 15€. Esta se compone por un receptor interno y un transmisor inalámbrico.

Receptor

Termómetro (transmisor)

Detalle del circuito interno
Tanto el receptor como el transmisor tienen indicado que funcionan en 433Mhz, así que un receptor barato de esa frecuencia de los que se encuentran en ebay muy fácilmente, debería recibir la información.


 El problema no es recibir los datos sino saber interpretarlos. Después de intentar hacer un analizador de protocolo improvisado por el arduino, terminé por decantarme por una solución rápida: utilizar la placa de sonido de la PC y recibir los datos con el Audacity.

Utilizando un jack conectamos la salida del receptor de 433Mhz a la entrada de la PC. Como se ve en la foto, no he conectado la masa porque el receptor se está alimentando del USB así que usa la masa de la PC. El termómetro tiene un botón que al presionar transmite la tempertura. Se usa para sincronizar con el receptor pero vamos a usarlo para no tener que esperar a que el termómetro decida transmitir. Simplemente ponemos a grabar en el Audacity, presionamos el botón y cuando vemos que ha terminado de transmitir, paramos la grabación.

El resultado es más o menos este:

Lógicamente, la placa de sonido tiene un capacitor a la entrada así que no podemos esperar que se vea tan bien como un analizador de protocolo, pero al menos nos servirá para investigar un poco qué transmite. Para eso necesitamos ver con más detalle los pulsos.

No se si ocurrirá en todas las PCs pero yo descubrí que en la mia, la información capturada por la placa de sonido sale invertida. O sea que los pulsos cortos hacia abajo que se ven son en realidad unos (el trnsmisor envía la portadora) y los pulsos hacia arriba son ceros (no se transmite portadora). Así que para que quede claro lo primero que voy a hacer es invertir la señal con el Audacity.


Después de eso, con un poco de zoom se puede ver claramente que el transmisor siempre envía pulsos más o menos de medio milisegundo (500 microsegundos), con diferentes separaciones entre ellos que indican los unos y los ceros. De esta manera, la energía consumida al transmitir es poca porque siempre son pulsos cortos y la verdadera información no está en los pulsos en sí sino en los espacios entre ellos.

Pulso de transmisión (500 microsegundos)

 Al observar los datos capturados pude descubrir que se envían tres tamaños diferentes de pulsos: 2 milisegundos para los ceros, 5 milisegundos para los unos y 10 milisegundos para la separación entre paquetes.

Cero binario (2 milisegundos)

Uno binario (5 milisegundos)

Fin de paquete (10 milisegundos)



Ahora bien, observando la transmisión también se puede ver que el termómetro envía los datos repetidos ocho veces para que el receptor pueda compararlos y evitar errores.


En el programa yo voy a verificar que reciba lo mismo al menos 4 veces. Pero primero hay que investigar el significado de estos datos transmitidos. Haciendo varias pruebas podemos determinar al menos cuáles son los bits que indican la temperatura. También hay un bit que indica si el dato ha sido transmitido automáticamente o se ha pulsado el botón de transmisión.

Hay 2 bits que se usan para indicar el "canal": en el termómetro hay una llave para seleccionar 1,2 o 3 y transmite ese número codificado en dos bits binarios.

Otra cosa que descubrí es que parte del paquete transmitido contiene un identificador del termómetro, que se elige en el momento de ponerle las pilas. Si sacamos las pilas y las ponemos de nuevo elige otro número diferente. El receptor se sincroniza con el número del termómetro y después ignora los datos si el número no concuerda.

Seguramente otros bits corresponden con la señal de batería baja pero todavía no he descubierto cuáles son.

El software

Ahora vien la parte del programa. Para eso conectamos el termómetro al Arduino y comenzamos a trabajar.


Viendo en internet otros proyectos que hacen cosas parecidas, he visto que reprograman el timer del arduino para medir el tiempo. Yo he preferido evitar hacer modificaciones grandes para no interferir con otras bibliotecas que puedan necesitarlo (las de sonidos por ejemplo, o el receptor de infrarrojo). Así que voy a usar una interrupción "pin change" porque se pueden aplicar a cualquier patita. http://www.arduino.cc/playground/Code/Interrupts

Para eso he utilizado una biblioteca que ya trae armado todo lo necesario: http://code.google.com/p/arduino-pinchangeint/

Sencillamente en el setup,

#include <PinChangeInt.h>

void setup() {
    pinMode(DATA, INPUT);
    PCintPort::attachInterrupt(DATA, &cambio, CHANGE);
    }
De esta forma, cada vez que haya un cambio en la patita "DATA", se ejecutará la función cambio(). Dentro de esa función lo primero será hacer un digitalRead(DATA) para obtener el valor que provocó el cambio. El inconveniente de este sistema es que si hay cambios muy rápidos podría llegar a perderse información, pero no es nuestro caso.

Recepción de los bits

La función cambio() será la encargada de medir la duración de los pulsos y determinar cuándo hay una transmisión válida. Para eso, lo que vamos a hacer es verificar que haya unos que duren 500 microsegundos, o par ser más flexibles, entre 400 y 900.


Lo primero que hacemos es obtener el contador de microsegundos del Arduino.

m=micros();

A continuación verificamos si el cambio que ha venido es una subida (un uno) o una bajada (un cero).

if (digitalRead(DATA)==HIGH){  //flanco de subida

Si es una subida, vamos a memorizar el momento en que vino el pulso.

    hmicros=m; 

Después haremos más cosas en el flanco de subida (calcular cuánto tiempo estuvo en cero) pero por lo pronto dejémoslo para después y hagamos un:

    return;
    }

Hecho esto, si llegamos a este punto y no se hizo el return es porque no era un flanco de bajada, y lo que vamos a hacer es calcular cuánto tiempo estuvo en uno. Esto lo hacemos restando el contador actual de microsegundos (que habíamos copiado a m al principio) menos hmicros, que debería tener el valor de microsegundos que había cuando vino el flanco de subida.

//flanco de bajada
dur_high=m-hmicros;
Teniendo la duración del pulso, vamos a ver si tiene entre 400 y 900 microsegundos. Si es así levantamos un flag. De lo contrario lo que tenemos que hacer es bajar ese flag y algunas cosas más que agregaremos después (limpiar los datos que estábamos intentando recibir).

if (dur_high>400L && dur_high<900L) {
    recibiendo=1;
    }
else {
    recibiendo=0;
    }
}

Ahora tenemos un flag llamado "recibiendo" que nos dice que el cero que va a venir a continuación es parte de una transmisión válida. Sólo falta calcular cuál es su duración. Para eso agregaremos el cálculo en el if del flanco de subida (que es cuando se acaba el cero) y en el flanco de bajada memorizaremos el tiempo del comienzo del cero igual que lo memorizamos el comienzo del uno en el flanco de subida. La función entonces quedaría así:


m=micros();
if (digitalRead(DATA)==HIGH){  //flanco de subida
    hmicros=m;  

    dur_low=m-lmicros;
    return;
    }    
//flanco de bajada

lmicros=m;
dur_high=m-hmicros;
//si es un pulso de 400 a 900 us estamos recibiendo datos
if (dur_high>400L && dur_high<900L) {
    recibiendo=1;
    }
else {
    bits_paquete=0;
    recibiendo=0;
    paquete=0L;
    }

}


Ahora en el pulso de subida tenemos dur_low que nos dice cuánto duró el último cero. Lo que hay que hacer ahora ver si era de 2, 5 o 10 milisegundos. Pero eso sólo lo haremos si el flag "recibiendo" está en 1:


    if (recibiendo){
       if (dur_low<1800L) return; //tiene que tener por lo menos 2ms
       if (dur_low>8000L) { //si es un pulso de 10ms, es un fin de paquete
           digitalWrite(13,HIGH);
           procesar_paquete();
           return;
           }
       digitalWrite(13,LOW);           
       agregar_pulso(dur_low>3000L);  //si el pulso es de 5ms, es un 1, si es de 2 ms es un 0
       }


A demás he agregado un digitalWrite(13,HIGH) cuando llega un pulso largo (más de 8 milisegundos) que indica el fin de paquete, y digitalWrite(13,LOW) para apagarlo después de procesar el paquete que nos ha llegado.

La función procesar_paquete() se ejecuta cuando viene el pulso largo para hacer lo que haya que hacer con los datos que nos han llegado.

Finalmente la función agregar_pulso() lo que hará será agregar un 1 o un 0 al buffer donde estamos guardando el paquete que está llegando. el valor a agregar será un 1 si dur_low (la duración del cero) es de más de 3 milisegundos (los unos son de 5ms y los ceros de 2).

La función agregar_pulso es muy sencilla:

void agregar_pulso(bool valor){
   paquete=(paquete<<1) | valor;
   bits_paquete++;
}

La variable "paquete" es un unsigned long global (32 bits). Primero desplazamos todos los bits hacia la izquierda una posición, y le hacemos un "or" con el valor que ha venido para insertar el nuevo bit. A demás incrementamos el contador "bits_paquete" que nos servirá para saber si tiene los 28 bits que tienen todos los paquetes válidos.

Ahora veamos cómo procesamos este paquete. Resulta que tenemos un problema: no se puede usar el puerto serie durante una interrupción porque podemos perder datos o fallar el envío por el puerto. Así que lo que vamos a hacer es meter los datos en un buffer. Una función en el lazo principal, fuera de la interrupción, deberá estar verificando si hay algo en el buffer para leerlo y transmitirlo.

Para el buffer voy a usar una interesante biblioteca que implementa un buffer anillo y se encarga de todo: http://siggiorn.com/arduino-circular-byte-buffer/ Así que lo único que tenemos que hacer es declarar el buffer:

#include <ByteBuffer.h>

En el setup inicializarlo:

void setup(){
   buffer.init(20);
   buffer.clear();

Y después lo usamos en la función procesar_paquete. Lo que vamos a hacer es simplemente insertar en el buffer la longitud del paquete y a continuación los datos en sí. Después limpiamos la información para dejar listo para el siguiente grupo de bits.

void procesar_paquete(){
   buffer.put(bits_paquete);
   buffer.putLong(paquete);
   paquete=0L;
   bits_paquete=0;
}
El siguiente paso es en el lazo principal verificar si hay suficiente información en el buffer para procesar. Para que haya un paquete disponibie debe haber al menos 5 bytes: 1 de la longitud y 4 de datos. Si no es así no hacemos nada. Si tenemos 5 bytes, traemos la información del buffer y si el paquete mide 28 bites, 
void loop() {
if (buffer.getSize()>4){
   l=buffer.get();  
   p=buffer.getLong();
   
   if (l==28){
       tiempo=millis()-m;
       m=millis();
       if (estado==0){ //esperando repeticiones
          if(ant==p) rep++;
          else rep=0;
          
          if (rep>4) {
             estado=1;
             mostrar_dato(p);
             }
          }
       else if(estado==1){ //al menos 4 repeticiones
          if(ant!=p || tiempo>1000) estado=0;//ya no se repite
          }
       ant=p;
       }  //l==28
   }//buffer con datos

}

Interpretación del paquete binario

Lo que nos está faltando es interpretar lo que significan los bits que transmite el termómetro.

Ya dijimos que lo que transmite son 28 bits, así que para averiguar lo que significan es necesario acumular una buena cantidad de datos y compararlos. Yo he descubierto en qué parte del paquete se transmite la temperatura, el canal, y el indicador del botón de sincronización. Tuve que meter el termómetro en el congelador para mirar tranquilo cómo transmitía los negativos. Con eso me basta porque sólo voy a recibir datos pero sería interesante entender el paquete completo porque así se podría crear un transmisor para emular un termómetro de estos.  La temperatura se transmite en grados Celsius en 12 bits. Al convertirlo a decimal el último dígito son las décimas de grado. Para extraer unos determinados bits del número, la forma más sencilla es hacer una función AND con una máscara, o sea con un número que contenga los bits que nos interesan en uno y ceros en los bits que no nos hacen falta. Después se hace un desplazamiento a derecha para descartar los dígitos de la derecha que no nos interesen. Por ejemplo, para extraer los tres primeros bits (de la izquierda) hacemos primero un AND con 0b1110000000000000000000000000 y después desplazamos a derecha 25 veces (recordemos que el paquete tiene 28 bits).

a=(p & 0b1110000000000000000000000000) >> 25;

Los negativos se transmiten mediante el complemento a dos pero como en el Arduino no hay un entero con signo de 12 bits, lo que hice fue directamente convertir a punto flotante, dividir por 10, y con un if detecto si el número se pasa de 2048 (o sea de 204,8 en punto flotante) y resto los 4096 (409,6) para ajustar al negativo correspondiente. Al final la rutina queda así:
void mostrar_dato(unsigned long p){
float temp;
bool bot;
byte canal=0;
byte a;
word b;
byte c;
Serial.print("datos:");
Serial.println(p,BIN);
/*
4 bits  ???? (va variando)
8 bits ? (fijo)

12 bits temperatura en celsius
2 bits canal
2 bits ?? (fijo)
*/


a=(p & 0b1110000000000000000000000000) >> 25;
b=(p & 0b0000111111110000000000000000) >> 16;
a=(p & 0b0000000000000000000000000011);

bot=(p & 0b0001000000000000000000000000 !=0);

temp=(0.0+((p & 0b0000000000001111111111110000) >> 4)) /10;
if (temp>204.8) temp-=409.6;

canal=(p & 0b0000000000000000000000001100) >> 2;

Serial.print("temp:");
Serial.println(temp);

Serial.print("boton:");
Serial.println(bot);

Serial.print("canal:");
Serial.println(canal);


Serial.print(a,BIN);
Serial.print(" ");
Serial.print(b,HEX);
Serial.print(" ");
Serial.println(c,BIN);

}

Para convertir esta información que genera el programa en un archivo CSV fácilmente procesable en una planilla de cálculos, hice un pequeño programa en python:


#!/usr/bin/python

import serial
import datetime
import sys

ser = serial.Serial('/dev/arduino', 115200)
while 1:
   a=ser.readline()
   l=a.split(':');
   if l[0] == 'datos':
      now = datetime.datetime.now()
      print now.strftime("%Y-%m-%d %H:%M:%S"),
      print '\t',
      print "%28s" % l[1].strip(),

   if l[0] == 'temp':
      print '\t',
      print l[1].strip()
      sys.stdout.flush()


Resultado:

Con los datos que genera el programa en python, puedo fácilmente abrir el CSV desde el libreoffice y graficar:



El programa:


A continuación el programa completo del Arduino:

#include <PinChangeInt.h>
#include <ByteBuffer.h>


#define DATA 8
#define BUFLEN 20
#define MAXBITS 32


ByteBuffer buffer;

unsigned long paquete=0L;
byte bits_paquete=0;


//////////////////////////////////////////////////

void cambio() {
unsigned long m;
static unsigned long dur_low;
static unsigned long dur_high;
static bool recibiendo=0;
static unsigned long lmicros=0;
static unsigned long hmicros=0;

m=micros();
if (digitalRead(DATA)==HIGH){  //flanco de subida
    hmicros=m;  
  
    dur_low=m-lmicros;

    //si el ultimo pulso alto duro 1ms, es un separador de pulsos y hay que procesar este dato
    if (recibiendo){
       if (dur_low<1800L) return; //tiene que tener por lo menos 1,8ms
       if (dur_low>8000L) { //si es un pulso de 10ms, es un fin de paquete
           digitalWrite(13,HIGH);
           procesar_paquete();
           return;
           }
       digitalWrite(13,LOW);           
       agregar_pulso(dur_low>3000L);  //si el pulso es de 5ms, es un 1, si es de 2 ms es un 0
       }
    
    
    return;
    }    
//flanco de bajada

lmicros=m;
dur_high=m-hmicros;
//si es un pulso de 0,5ms estamos recibiendo datos
if (dur_high>400L && dur_high<900L) {
    recibiendo=1;

    }
else {
    bits_paquete=0;
    recibiendo=0;
    paquete=0L;
    }

}

////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
void agregar_pulso(bool valor){
paquete=(paquete<<1) | valor;
bits_paquete++;

}


////////////////////////////////////////////////////////////////////////////
void procesar_paquete(){
buffer.put(bits_paquete);
buffer.putLong(paquete); 
paquete=0L;
bits_paquete=0;

}


////////////////////////////////////////////////////////////////////////////
void setup() {
  buffer.init(20);
  buffer.clear();
  
  pinMode(DATA, INPUT);
  pinMode(13, OUTPUT);
  digitalWrite(13,LOW);
  pinMode(12, OUTPUT);
  PCintPort::attachInterrupt(DATA, &cambio, CHANGE);
  Serial.begin(115200);
  Serial.println("---------------------------------------");
}

/////////////////////////////////////////////////////////////////////////////////////
void mostrar_dato(unsigned long p){
float temp;
bool bot;
byte canal=0;
byte a;
word b;
byte c;
Serial.print("datos:");
Serial.println(p,BIN);
/*
4 bits  ???? (va variando)

8 bits ? (fijo)

12 bits temperatura en celsius
2 bits canal
2 bits ?? (fijo)
*/


a=(p & 0b1110000000000000000000000000) >> 25;
b=(p & 0b0000111111110000000000000000) >> 16;
a=(p & 0b0000000000000000000000000011);

bot=(p & 0b0001000000000000000000000000 !=0);

temp=(0.0+((p & 0b0000000000001111111111110000) >> 4)) /10;
if (temp>204.8) temp-=409.6;

canal=(p & 0b0000000000000000000000001100) >> 2;

Serial.print("temp:");
Serial.println(temp);

Serial.print("boton:");
Serial.println(bot);

Serial.print("canal:");
Serial.println(canal);


Serial.print(a,BIN);
Serial.print(" ");
Serial.print(b,HEX);
Serial.print(" ");
Serial.println(c,BIN);


}



/////////////////////////////////////////////////////////////////////////////////////
unsigned long p;
unsigned long ant=0;
unsigned long rep=0;
unsigned long tiempo=0,m=0;
byte l;
byte estado=0;

void loop() {
  
if (buffer.getSize()>4){

   l=buffer.get();  
   p=buffer.getLong();
   
   if (l==28){
       tiempo=millis()-m;
       m=millis();
       if (estado==0){ //esperando repeticiones
          if(ant==p) rep++;
          else rep=0;
          
          if (rep>4) {
             estado=1;

             mostrar_dato(p);
             }
          }
       else if(estado==1){ //al menos 4 repeticiones
          if(ant!=p || tiempo>1000) estado=0;//ya no se repite
          }
       ant=p;

       }  //l==28
   }//buffer con datos

}