viernes, 15 de agosto de 2025

Monitorización y Control del Concentrador Tapo H200

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.

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 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 y el nombre y nivel de señal 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 -r .result.responses[1].result.child_device_list[] | jq -r ".nickname, .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 se puede enviar una notificación con el nombre del dispositivo y el nivel de señal. El nombre del dispositivo está codificado en Base64. Para decodificarlo disponemos del comando base64. Existen servicios como Pushover con los que enviar notificaciones.

. /usr/local/lib/notifications.sh

SOUND="/usr/local/share/tapo/alarm-siren-sound.wav"
MIN_SIGNAL_LEVEL=2

if [ "$siren_status" = "on" ]
then
    aplay -q $SOUND
fi

echo "$child_devices" | while read base64_nickname
do
    read signal_level

    if [ $signal_level -lt $MIN_SIGNAL_LEVEL ]
    then
        nickname=$(echo $base64_nickname | base64 -d)
        send_notification "Tapo Hub: $nickname Signal Level: $signal_level"
    fi
done
# vi /etc/notifications.conf

NOTIFICATIONS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
NOTIFICATIONS_USER="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
NOTIFICATIONS_SERVER="https://api.pushover.net/1/messages.json"

# vi /usr/local/lib/notifications.sh

. /etc/notifications.conf

send_notification () {
    curl -s -F token=$NOTIFICATIONS_TOKEN -F user=$NOTIFICATIONS_USER -F message="$1" $NOTIFICATIONS_SERVER > /dev/null
}

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=$((seq+1))
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.

if [ "$1" == "siren" ]
then
    status=$2
    REQUESTS="{\"method\": \"setSirenStatus\", \"params\": {\"siren\": {\"status\": \"$status\"}}}"
    
    ..........

else

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. A continuación se puede ver el archivo de configuración y el programa completo.

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
#!/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" == "siren" ]
then
    status=$2
    REQUESTS="{\"method\": \"setSirenStatus\", \"params\": {\"siren\": {\"status\": \"$status\"}}}"
    init
    send_request
    
    if [ $error_code = 0 ]
    then
        read_result
        echo $result
    else
        echo $response
    fi


else
    init

    while true
    do
        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 -r .result.responses[1].result.child_device_list[] | jq -r ".nickname, .signal_level")
	
        if [ "$siren_status" = "on" ]
        then
            aplay -q $SOUND
        fi

        echo "$child_devices" | while read base64_nickname
        do
            read signal_level

            if [ $signal_level -lt $MIN_SIGNAL_LEVEL ]
            then
                nickname=$(echo $base64_nickname | base64 -d)
                send_notification "Tapo Hub: $nickname Signal Level: $signal_level"
            fi
        done

        seq=$((seq+1))
        sleep 5
    done
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

[Install]
WantedBy=multi-user.target

# systemctl enable tapo

No hay comentarios:

Publicar un comentario