Al concentrador (hub en inglés) Tapo H200 se pueden conectar múltiples sensores inalámbricos: movimiento, apertura, temperatura, humedad y agua. El concentrador tiene un pequeño altavoz con el que hacer sonar una sirena. Si queremos monitorizar el estado de los sensores o el altavoz podemos conectar a través de Ethernet o WiFi mediante el protocolo de comunicación del concentrador.
En primer lugar hay que asignar una dirección IP al concentrador. Con el comando arp es posible ver su dirección MAC. En el servidor DHCP podemos asignar una dirección IP a esa dirección MAC. Si utilizamos NetworkManager como servidor DHCP debemos configurarlo en el archivo dnsmasq.conf.
# arp -i eth0 Address HWtype HWaddress Flags Mask Iface 10.42.0.68 ether 20:23:51:04:31:43 C eth0 # vi /etc/NetworkManager/dnsmasq-shared.d/dnsmasq.conf dhcp-host=20:23:51:04:31:43,10.42.0.2,hub,infinite # systemctl restart NetworkManager
El protocolo se basa en la transmisión de documentos JSON a través de HTTP. Para la autenticación se utiliza el usuario "admin" y la contraseña usada en la aplicación Tapo. Podemos usar cualquier lenguaje de programación, yo he usado un "script" Bash en Linux. La comunicación con el concentrador se realiza en tres pasos.
En primer lugar se envía una petición de autenticación con el método login, un "nonce" de 8 bytes en formato hexadecimal en mayúsculas y el usuario "admin". Los bytes aleatorios para el "nonce" se pueden leer de /dev/urandom y codificarlos en hexadecimal con xxd. Para la comunicación HTTP se puede utilizar cURL.
USERNAME="admin"
HUB="10.42.0.2"
client_nonce=$(dd if=/dev/urandom bs=8 count=1 status=none | xxd -p -u)
request="{\"method\": \"login\", \"params\": {\"cnonce\": \"$client_nonce\", \"username\": \"$USERNAME\"}}"
response=$(curl -ksd "$request" https://$HUB)
En la respuesta recibiremos un "nonce" del concentrador que podemos extraer del documento JSON con el comando jq. Este "nonce" lo utilizaremos junto al "nonce" creado anteriormente y la contraseña para crear una "contraseña digest" que nos permita completar la autenticación con una nueva petición HTTP.
En la creación de la "contraseña digest" se utiliza la "función hash" SHA-256. Para ello tenemos el comando sha256sum. Es conveniente crear una función con el código de creación del "hash" porque lo vamos a necesitar varias veces en la comunicación. El "hash" debe usar letras mayúsculas. En lugar de guardar la contraseña de la aplicación en el "script" o un archivo de configuración se puede guardar su "hash" para más seguridad.
PASSWORD_HASH="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
sha256hash () {
echo -n $1 | sha256sum | head -c 64 | tr [:lower:] [:upper:]
}
server_nonce=$(echo $response | jq -r .result.data.nonce)
digest_passwd=$(sha256hash $PASSWORD_HASH$client_nonce$server_nonce)$client_nonce$server_nonce
request="{\"method\": \"login\", \"params\": {\"cnonce\": \"$client_nonce\", \"digest_passwd\": \"$digest_passwd\", \"username\": \"$USERNAME\"}}"
response=$(curl -ksd "$request" https://$HUB)
Una vez completada la autenticación recibiremos un documento JSON con los parámetros stok y start_seq. El parámetro stok es necesario incluirlo en la URL de las peticiones. El valor de start_seq es el número de inicio de la secuencia de peticiones. Debemos asignarlo a una variable (por ejemplo seq), incluirlo en la cabecera Seq y aumentar su valor después de cada petición.
Para recibir información del concentrador se le envían peticiones codificadas en JSON. Se pueden enviar una o varias peticiones a la vez. Para ver el estado de la sirena y los dispositivos conectados se utilizan peticiones con los métodos getSirenStatus y getChildDeviceList. Estas peticiones se introducen en una petición con el método multipleRequest y se cifra con AES-128-CBC. Para ello se crean una clave (key) y un vector de inicialización (iv) de 32 bytes/256 bits. En la creación de la clave se utiliza la cadena de texto "lsk" y en la creación del vector "ivb".
El resultado se codifica en Base64 y se introduce en una petición con el método securePassthrough. Esta petición no debe tener espacios en blanco. El cifrado se puede hacer con el comando openssl. Por último se crea una etiqueta (tag) con el "hash" de la contraseña, el "nonce" creado al principio, la petición securePassthrough y el valor de seq. Esta etiqueta se incluye en la petición HTTP mediante la cabecera Tapo_tag.
REQUESTS="{\"method\": \"getSirenStatus\", \"params\": {\"siren\": {}}}, {\"method\": \"getChildDeviceList\", \"params\": {\"childControl\": {\"start_index\": 0}}}"
stok=$(echo $response | jq -r .result.stok)
seq=$(echo $response | jq -r .result.start_seq)
key=$(sha256hash "lsk"$client_nonce$server_nonce$(sha256hash $client_nonce$PASSWORD_HASH$server_nonce) | head -c 32)
iv=$(sha256hash "ivb"$client_nonce$server_nonce$(sha256hash $client_nonce$PASSWORD_HASH$server_nonce) | head -c 32)
request="{\"method\": \"multipleRequest\", \"params\": {\"requests\": [$REQUESTS]}}"
encoded_request=$(echo $request | openssl enc -aes-128-cbc -e -a -A -K $key -iv $iv);
passthrough="{\"method\":\"securePassthrough\",\"params\":{\"request\":\"$encoded_request\"}}"
tag=$(sha256hash $(sha256hash $PASSWORD_HASH$client_nonce)$passthrough$seq)
response=$(curl -ksd "$passthrough" -H "Seq: $seq" -H "Tapo_tag: $tag" https://$HUB/stok=$stok/ds)
La respuesta también está cifrada con la misma contraseña y vector de inicialización. Dentro de la respuesta hay una respuesta por cada petición enviada. El comando jq nos permite extraer del documento JSON la información que nos interese. Por ejemplo podemos extraer el estado de la sirena del concentrador y el nombre, nivel de señal y nivel de interferencia ("jamming") de los dispositivos conectados.
result=$(echo $response | jq -r .result.response | openssl enc -aes-128-cbc -d -a -A -K $key -iv $iv) siren_status=$(echo $result | jq -r .result.responses[0].result.status) child_devices=$(echo $result | jq .result.responses[1].result.child_device_list[] | jq -r ".nickname, .signal_level, .jamming_signal_level")
Con los valores obtenidos es posible realizar diferentes acciones. Si la sirena está activada, podemos reproducir un archivo de sonido con un altavoz más potente que el del concentrador mediante el comando aplay. El nivel de señal es un número de 0 a 3. Si uno de los dispositivos conectados tiene un nivel de señal muy bajo o un nivel de interferencia muy alto se puede enviar una notificación con el nombre del dispositivo y los niveles. El nombre del dispositivo está codificado en Base64. Para decodificarlo disponemos del comando base64.
. /usr/local/lib/notifications.sh
SOUND="/usr/local/share/tapo/alarm-siren-sound.wav"
MIN_SIGNAL_LEVEL=2
MAX_JAMMING_SIGNAL_LEVEL=1
if [ "$siren_status" = "on" ]
then
aplay -q $SOUND
fi
while read base64_nickname
do
read signal_level
read jamming_signal_level
if [ $signal_level -lt $MIN_SIGNAL_LEVEL ] || [ $jamming_signal_level -gt $MAX_JAMMING_SIGNAL_LEVEL ]
then
nickname=$(echo $base64_nickname | base64 -d)
message="Tapo Hub: $nickname - Signal: $signal_level Jamming: $jamming_signal_level"
send_notification "$message"
fi
done <<< $child_devices
En el proceso de autenticación es posible aumentar la seguridad autenticando al concentrador. Esto se consigue comprobando que el valor del parámetro device_confim de la primera petición "login" es el esperado.
device_confirm=$(echo $response | jq -r .result.data.device_confirm)
expected_device_confirm=$(sha256hash $client_nonce$PASSWORD_HASH$server_nonce)$server_nonce$client_nonce
if [ "$device_confirm" != "$expected_device_confirm" ]
then
send_notification "Tapo Hub: Device confirm mismatch"
exit
fi
Al monitorizar el concentrador y sus dispositivos conectados no es necesario volver a hacer la autenticación cada vez que se realiza la petición. Solo debemos aumentar el valor de seq, volver a crear la etiqueta (tag) y enviar la petición. Unicamente hay que realizar de nuevo la autenticación y crear la petición securePassthrough si caduca la sesión. Cuando en la respuesta recibimos un error_code distinto de 0 es que la petición ha fallado por haber caducado la sesión u otra causa. Entre cada petición podemos parar la ejecución del programa con el comando sleep durante segundos, minutos u horas dependiendo de lo que necesitemos.
error_code=$(echo $response | jq -r .error_code)
if [ $error_code != 0 ]
then
..........
fi
..........
((seq++))
sleep 5
Además de leer los parámetros también es posible modificar alguno de ellos. Por ejemplo con el método setSirenStatus se modifica el estado de la sirena. Se puede añadir al programa la posibilidad de ejecutarlo con argumentos que le indiquen la acción a realizar. Con el método getDeviceInfo se ve información del concentrador. Los métodos getLedStatus y setLedStatus hacen posible ver y modificar el estado de la luz.
if [ "$1" = "monitor" ]
then
..........
else
case $1 in
"siren")
if [ -z $2 ]
then
REQUESTS="{\"method\": \"getSirenStatus\", \"params\": {\"siren\": {}}}"
else
REQUESTS="{\"method\": \"setSirenStatus\", \"params\": {\"siren\": {\"status\": \"$2\"}}}"
fi
;;
"led")
if [ -z $2 ]
then
REQUESTS="{\"method\": \"getLedStatus\", \"params\": {\"led\": {}}}"
else
REQUESTS="{\"method\": \"setLedStatus\", \"params\": {\"led\": {\"config\": {\"enabled\": \"$2\"}}}}"
fi
;;
"info")
REQUESTS="{\"method\": \"getDeviceInfo\", \"params\": {\"device_info\": {}}}"
;;
"devices")
REQUESTS="{\"method\": \"getChildDeviceList\", \"params\": {\"childControl\": {\"start_index\": 0}}}"
;;
*)
echo "Usage: tapo.sh monitor | siren [on|off] | led [on|off] | info | devices"
exit
;;
esac
..........
fi
Las variables de configuración las podemos guardar en un archivo de configuración como /etc/tapo.conf y el código en un archivo de un directorio de la variable $PATH, por ejemplo /usr/local/bin/tapo.sh. El código de la comunicación se puede dividir en tres funciones para cubrir las diferentes necesidades de la monitorización y la modificación de parámetros:
- init: Autenticación y creación de la petición securePassthrough.
- send_request: Creación de la etiqueta (tag) y envío de la petición.
- read_result: Lectura del resultado de la petición.
La monitorización se ejecuta en un bucle infinito, cuando hay un error se ejecuta la función init y se reinicia el bucle. Con el objetivo de no repetir el envío de la misma notificación cada poco tiempo, es necesario comprobar si el mensaje ya ha sido enviado. Para ello tenemos que guardar un registro de los mensajes enviados durante el tiempo necesario.
Una forma de hacerlo es usar un "array" asociativo en el que la clave es el mensaje y el valor el momento de envío. El "array" se inicializa con declare -A y se comprueba si un mensaje ha sido enviado y está en el "array" con -v. Una vez transcurrido el tiempo en el que no se debe repetir el mensaje, se elimina del "array" con unset. Para registrar el momento de envío podemos utilizar el Tiempo Unix con ayuda del comando date.
declare -A sent_messages
..........
time=$(date +%s)
..........
if [ ! -v sent_messages["$message"] ]
then
sent_messages["$message"]=$time
send_notification "$message"
fi
..........
for message in "${!sent_messages[@]}"
do
message_time=${sent_messages["$message"]}
if [ $((message_time + 1800)) -lt $time ]
then
unset sent_messages["$message"]
fi
done
A continuación se puede ver el archivo de configuración y el programa completo. También están disponibles en un repositorio de GitHub.
USERNAME="admin"
PASSWORD_HASH="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
HUB="10.42.0.2"
REQUESTS="{\"method\": \"getSirenStatus\", \"params\": {\"siren\": {}}}, {\"method\": \"getChildDeviceList\", \"params\": {\"childControl\": {\"start_index\": 0}}}"
SOUND="/usr/local/share/tapo/alarm-siren-sound.wav"
MIN_SIGNAL_LEVEL=2
MAX_JAMMING_SIGNAL_LEVEL=1
#!/usr/bin/bash
. /etc/tapo.conf
. /usr/local/lib/notifications.sh
sha256hash () {
echo -n $1 | sha256sum | head -c 64 | tr [:lower:] [:upper:]
}
init () {
client_nonce=$(dd if=/dev/urandom bs=8 count=1 status=none | xxd -p -u)
request="{\"method\": \"login\", \"params\": {\"cnonce\": \"$client_nonce\", \"username\": \"$USERNAME\"}}"
response=$(curl -ksd "$request" https://$HUB)
server_nonce=$(echo $response | jq -r .result.data.nonce)
device_confirm=$(echo $response | jq -r .result.data.device_confirm)
expected_device_confirm=$(sha256hash $client_nonce$PASSWORD_HASH$server_nonce)$server_nonce$client_nonce
if [ "$device_confirm" != "$expected_device_confirm" ]
then
send_notification "Tapo Hub: Device confirm mismatch"
exit
fi
digest_passwd=$(sha256hash $PASSWORD_HASH$client_nonce$server_nonce)$client_nonce$server_nonce
request="{\"method\": \"login\", \"params\": {\"cnonce\": \"$client_nonce\", \"digest_passwd\": \"$digest_passwd\", \"username\": \"$USERNAME\"}}"
response=$(curl -ksd "$request" https://$HUB)
stok=$(echo $response | jq -r .result.stok)
seq=$(echo $response | jq -r .result.start_seq)
key=$(sha256hash "lsk"$client_nonce$server_nonce$(sha256hash $client_nonce$PASSWORD_HASH$server_nonce) | head -c 32)
iv=$(sha256hash "ivb"$client_nonce$server_nonce$(sha256hash $client_nonce$PASSWORD_HASH$server_nonce) | head -c 32)
request="{\"method\": \"multipleRequest\", \"params\": {\"requests\": [$REQUESTS]}}"
encoded_request=$(echo $request | openssl enc -aes-128-cbc -e -a -A -K $key -iv $iv);
passthrough="{\"method\":\"securePassthrough\",\"params\":{\"request\":\"$encoded_request\"}}"
}
send_request () {
tag=$(sha256hash $(sha256hash $PASSWORD_HASH$client_nonce)$passthrough$seq)
response=$(curl -ksd "$passthrough" -H "Seq: $seq" -H "Tapo_tag: $tag" https://$HUB/stok=$stok/ds)
error_code=$(echo $response | jq -r .error_code)
}
read_result () {
result=$(echo $response | jq -r .result.response | openssl enc -aes-128-cbc -d -a -A -K $key -iv $iv)
}
if [ "$1" = "monitor" ]
then
declare -A sent_messages
init
while true
do
time=$(date +%s)
send_request
if [ $error_code != 0 ]
then
init
continue
fi
read_result
siren_status=$(echo $result | jq -r .result.responses[0].result.status)
child_devices=$(echo $result | jq .result.responses[1].result.child_device_list[] | jq -r ".nickname, .signal_level, .jamming_signal_level")
if [ "$siren_status" = "on" ]
then
echo "Tapo Hub: Siren Status On"
aplay -q $SOUND
fi
while read base64_nickname
do
read signal_level
read jamming_signal_level
if [ $signal_level -lt $MIN_SIGNAL_LEVEL ] || [ $jamming_signal_level -gt $MAX_JAMMING_SIGNAL_LEVEL ]
then
nickname=$(echo $base64_nickname | base64 -d)
message="Tapo Hub: $nickname - Signal: $signal_level Jamming: $jamming_signal_level"
if [ ! -v sent_messages["$message"] ]
then
sent_messages["$message"]=$time
send_notification "$message"
fi
fi
done <<< $child_devices
for message in "${!sent_messages[@]}"
do
message_time=${sent_messages["$message"]}
if [ $((message_time + 1800)) -lt $time ]
then
unset sent_messages["$message"]
fi
done
((seq++))
sleep 5
done
else
case $1 in
"siren")
if [ -z $2 ]
then
REQUESTS="{\"method\": \"getSirenStatus\", \"params\": {\"siren\": {}}}"
else
REQUESTS="{\"method\": \"setSirenStatus\", \"params\": {\"siren\": {\"status\": \"$2\"}}}"
fi
;;
"led")
if [ -z $2 ]
then
REQUESTS="{\"method\": \"getLedStatus\", \"params\": {\"led\": {}}}"
else
REQUESTS="{\"method\": \"setLedStatus\", \"params\": {\"led\": {\"config\": {\"enabled\": \"$2\"}}}}"
fi
;;
"info")
REQUESTS="{\"method\": \"getDeviceInfo\", \"params\": {\"device_info\": {}}}"
;;
"devices")
REQUESTS="{\"method\": \"getChildDeviceList\", \"params\": {\"childControl\": {\"start_index\": 0}}}"
;;
*)
echo "Usage: tapo.sh monitor | siren [on|off] | led [on|off] | info | devices"
exit
;;
esac
init
send_request
if [ $error_code = 0 ]
then
read_result
case $1 in
"siren")
if [ -z $2 ]
then
echo $result | jq .result.responses[0].result
else
echo $result
fi
;;
"led")
if [ -z $2 ]
then
echo $result | jq -r .result.responses[0].result.led.config.enabled
else
echo $result
fi
;;
"info")
echo $result | jq .result.responses[0].result.device_info.basic_info
;;
"devices")
echo $result | jq .result.responses[0].result.child_device_list[]
;;
*)
echo $result
;;
esac
else
echo $response
fi
fi
Para ejecutar el programa en el inicio del sistema tenemos que crear un servicio de SystemD. El usuario que ejecute el servicio debe estar en el grupo audio para poder reproducir el archivo de audio. El servicio se debe ejecutar después de que la conexión de red usada para conectar con el concentrador esté activa. Esto se consigue configurando el servicio para que requiera y se ejecute después de network-online.target.
# chmod +x /usr/local/bin/tapo.sh # mkdir /usr/local/share/tapo # cp alarm-siren-sound.wav /usr/local/share/tapo # useradd -G audio tapo # vi /lib/systemd/system/tapo.service [Unit] Description=Tapo Service Requires=network-online.target After=network-online.target [Service] Type=simple User=tapo ExecStart=/usr/local/bin/tapo.sh monitor [Install] WantedBy=multi-user.target # systemctl enable tapo
No hay comentarios:
Publicar un comentario