Notificaciones de qBittorrent en Telegram a través de Home Assistant

Si tienes qBittorrent corriendo en Docker y un Home Assistant al que le pides cada vez más cosas, tarde o temprano surge la idea: quiero que me avise por Telegram cada vez que termina una descarga. Suena trivial, pero como veremos, el camino tiene alguna trampa. En este artículo repasamos, paso a paso, cómo montarlo de forma robusta.

El montaje de referencia es Home Assistant en Docker, qBittorrent (imagen linuxserver.io) también en Docker, HA sirviendo por HTTPS con certificado de Let’s Encrypt, y un bot de Telegram ya configurado como servicio de notificaciones. Ajusta las IP y nombres a tu entorno.

La idea general

qBittorrent tiene una función llamada “Ejecutar programa externo en torrent terminado”. La usaremos para que, al completar una descarga, lance un pequeño script que envíe los datos del torrent a un webhook de Home Assistant. HA recibe ese webhook, dispara una automatización y esta manda el mensaje a Telegram con el formato que queramos.

El flujo completo es: qBittorrent termina la descarga, ejecuta un script, el script hace una petición al webhook de HA, la automatización se dispara y llega la notificación a Telegram.

Paso 1: Crear el webhook en Home Assistant

Empezamos por el lado de HA. Un webhook trigger no necesita nada en configuration.yaml: basta con definir la automatización. Añade esto a tus automatizaciones

- id: qbittorrent_descarga_completada
  alias: qBittorrent - Descarga completada
  trigger:
    platform: webhook
    webhook_id: qbittorrent_completado   # ID
  action:
    - service: notify.notif_telegram_bot
      data:
        message: >-
          Descarga completada
          {{ "\n" }}Nombre: {{ trigger.json.name | default('desconocido') }}

La URL del webhook será https://TU_IP_HA:8123/api/webhook/qbittorrent_completado. Como no lleva autenticación, el webhook_id debe ser largo e impredecible: es tu única barrera. Los datos que enviemos llegarán disponibles en trigger.json.*.

Un apunte importante si sirves HA por HTTPS: el webhook hay que llamarlo con https:// y, si el certificado es autofirmado o el cliente no lo valida, con la opción de saltarse la verificación. Si lo llamas por HTTP plano, la conexión se cierra sin respuesta y te vuelves loco buscando el fallo donde no está.

Paso 2: Probar el webhook antes de seguir

Antes de tocar qBittorrent, conviene comprobar que HA recibe correctamente. Desde cualquier máquina de la red local:

curl -vk -X POST -H "Content-Type: application/json" \
  -d '{"name":"prueba"}' \
  https://TU_IP_HA:8123/api/webhook/qbittorrent_completado

La respuesta debe ser HTTP 200 OK. Si además ves en la cabecera algo como Server: Python/3.10 aiohttp, es tu Home Assistant respondiendo. Si obtienes “Empty reply from server”, casi seguro estás hablando en HTTP a un puerto que espera HTTPS: añade la s y la opción -k.

Paso 3: El script dentro del contenedor

Aquí está la decisión clave del montaje. Se puede intentar meter el comando curl completo directamente en el campo de qBittorrent, pero es frágil: los nombres de torrent llevan espacios y corchetes (por ejemplo [HDTV][Cap.105]), y el anidamiento de comillas para construir un JSON válido acaba rompiéndose. La solución limpia y fiable es un script que reciba los datos como argumentos y construya la petición él mismo.

Es la solucion limpia porque me volvi loco intentandolo directamente con comandos del estilo a este

/bin/bash -c 'curl -k -X POST -H "Content-Type: application/json" -d "{\"name\":\"$0\",\"category\":\"$1\",\"tags\":\"$2\",\"num_files\":\"$3\",\"size\":\"$4\",\"path\":\"$5\",\"tracker\":\"$6\"}" https://192.168.1.20:8123/api/webhook/qbittorrent_completado' "%N" "%L" "%G" "%C" "%Z" "%F" "%T"

Estos comandos dentro del docker funcionan bien , pero al ser ejecutados por qBittorrent los lanza pero no se ejecutan , es un problema endémico de qBittorrent en docker , ya que probe en varias versiones y en todas igual , ponerlo en un script externo nos ahorrara unas cuantas horas y algun dolor de cabeza

Nos gusta declarar cada variable con un nombre claro al principio; en un script un poco complejo se agradece muchísimo a la hora de leerlo. Crea el archivo dentro del contenedor, en una ruta persistente como /config:

#!/bin/bash

TORRENT_NAME=$1          # Nombre del torrent
TORRENT_CATEGORY=$2      # Categoría
TORRENT_TAGS=$3          # Tags (separados por coma)
TORRENT_CONTENT_PATH=$4  # Ruta del contenido (igual que root en multi-archivo)
TORRENT_ROOT_PATH=$5     # Ruta raíz (primer subdirectorio del torrent)
TORRENT_SAVE_PATH=$6     # Ruta de guardado
TORRENT_NUM_FILES=$7     # Número de archivos
TORRENT_SIZE=$8          # Tamaño del torrent (bytes)
TORRENT_TRACKER=$9       # Tracker actual

# Log de depuración: confirma que el script se ejecuta y con qué datos
echo "$(date) | $TORRENT_NAME | $TORRENT_SIZE bytes" >> /config/qbt_notify.log

/usr/bin/curl -k -X POST \
  -H "Content-Type: application/json" \
  -d "{\"name\":\"$TORRENT_NAME\",\"category\":\"$TORRENT_CATEGORY\",\"tags\":\"$TORRENT_TAGS\",\"num_files\":\"$TORRENT_NUM_FILES\",\"size\":\"$TORRENT_SIZE\",\"path\":\"$TORRENT_CONTENT_PATH\",\"tracker\":\"$TORRENT_TRACKER\"}" \
  https://TU_IP_HA:8123/api/webhook/qbittorrent_completado \
  >> /config/qbt_notify.log 2>&1

Dos detalles que ahorran horas de depuración. Primero, usar la ruta absoluta de curl (/usr/bin/curl, confírmala con which curl dentro del contenedor), porque el proceso que lanza qBittorrent puede tener un PATH mínimo y no encontrar el comando. Segundo, el registro en /config/qbt_notify.log: esa línea de log y la redirección de la salida de curl (>> ... 2>&1) son tu mejor amigo para saber si el script se ejecuta de verdad y qué devuelve.

Paso 4: Permisos correctos

Las imágenes de linuxserver.io ejecutan qBittorrent como el usuario abc (el PUID/PGID que configuras), no como root. Si creas el script como root, asegúrate de que abc puede ejecutarlo y escribir el log:

docker exec qbittorrent chown abc:abc /config/qbt_notify.sh
docker exec qbittorrent chmod 755 /config/qbt_notify.sh

Puedes verificar que todo funciona como lo hará qBittorrent ejecutando el script directamente como abc:

docker exec -u abc qbittorrent /config/qbt_notify.sh \
  "Peli de prueba [HDTV]" "" "" "/downloads/completas/Peli" \
  "/downloads/completas/Peli" "/downloads/completas" "2" "536815980" ""

Si al mirar /config/qbt_notify.log ves la línea con la fecha y el nombre, y la salida de curl indica éxito, el script está listo.

Paso 5: Configurar qBittorrent

Ahora sí, en la interfaz de qBittorrent ve a Herramientas → Opciones → Descargas, marca la casilla “Ejecutar programa externo en torrent terminado” y en el campo pon la llamada al script pasando los parámetros de qBittorrent, cada uno entre comillas para que los nombres con espacios lleguen enteros:

/config/qbt_notify.sh "%N" "%L" "%G" "%F" "%R" "%D" "%C" "%Z" "%T"

El orden importa y debe coincidir con el script. qBittorrent sustituye cada token por su valor: %N es el nombre, %L la categoría, %G las etiquetas, %F la ruta del contenido, %R la ruta raíz, %D la ruta de guardado, %C el número de archivos, %Z el tamaño en bytes y %T el tracker.

Paso 6: La automatización final, con formato e iconos

Con los datos llegando a HA, pulimos el mensaje. Usamos parse_mode: html porque, a diferencia de Markdown, no se atraganta con los corchetes [ ] de los nombres de episodios. Eso sí, escapamos &, < y > en los campos de texto libre por si algún nombre los contiene, respetando siempre el orden de escapar & en primer lugar:

- id: qbittorrent_descarga_completada
  alias: qBittorrent - Descarga completada
  trigger:
    platform: webhook
    webhook_id: qbittorrent_completado   # ID secreto, trátalo como una contraseña
  action:
    - service: notify.notif_telegram_bot
      data:
        message: >-
          ✅ <b>Descarga completada</b>

          {{ "\n" }}🎬 <b>Nombre:</b> {{ trigger.json.name | default('desconocido') | replace('&','&amp;') | replace('<','&lt;') | replace('>','&gt;') }}
          {{ "\n" }}📁 <b>Categoría:</b> {{ trigger.json.category | default('') | default('—', true) | replace('&','&amp;') | replace('<','&lt;') | replace('>','&gt;') }}
          {{ "\n" }}🏷️ <b>Etiquetas:</b> {{ trigger.json.tags | default('') | default('—', true) | replace('&','&amp;') | replace('<','&lt;') | replace('>','&gt;') }}
          {{ "\n" }}🗂️ <b>Nº archivos:</b> {{ trigger.json.num_files | default('?') }}
          {{ "\n" }}💾 <b>Tamaño:</b> {{ (trigger.json.size | default(0) | int(0) / 1073741824) | round(2) }} GB
          {{ "\n" }}📍 <b>Ruta:</b> {{ trigger.json.path | default('desconocida') | replace('&','&amp;') | replace('<','&lt;') | replace('>','&gt;') }}
          {{ "\n" }}🌐 <b>Tracker:</b> {{ trigger.json.tracker | default('') | default('—', true) | replace('&','&amp;') | replace('<','&lt;') | replace('>','&gt;') }}
        data:
          parse_mode: html

El patrón default('') | default('—', true) es útil: si un campo llega vacío (muchos torrents no tienen categoría ni etiquetas), muestra un guión en lugar de dejar el hueco en blanco. Y el size | default(0) | int(0) evita que el cálculo de gigabytes reviente si por algún motivo no llega el tamaño. Tras editar, recarga las automatizaciones desde Herramientas de desarrollador → YAML → Recargar automatizaciones.

Resolución de problemas: lo que aprendimos por el camino

Este montaje tiene varios puntos donde es fácil perder el rastro, así que vale la pena tenerlos presentes.

Si el curl de prueba devuelve “Empty reply from server”, el problema es de esquema: estás llamando por HTTP a un puerto HTTPS. Añade la s y la opción -k.

Si qBittorrent registra en su log “Ejecutando programa externo” pero no ocurre nada, el detalle a revisar es el quoting del comando y las rutas absolutas. Meter un curl con JSON directamente en el campo, con su maraña de comillas anidadas, suele fallar porque qBittorrent no parsea los escapes como lo haría una shell. Delegar en un script, como hicimos, elimina ese problema de raíz.

Si el mensaje de Telegram no llega pero el webhook sí entra en HA, mira el log de Home Assistant. Un error típico es UndefinedError: 'dict object' has no attribute...: significa que la plantilla intenta leer un campo que no viene en el JSON. Por eso ponemos default() en todos los campos. Otro clásico es que Markdown se atragante con los corchetes del nombre; de ahí el cambio a HTML.

El log de depuración /config/qbt_notify.log es lo que convierte “no funciona” en “aquí está el error”. Cuando todo esté rodado, puedes comentar esas líneas del script para no llenar el disco.

Conclusión

Con este montaje, cada descarga que completa qBittorrent te llega al móvil por Telegram, con un mensaje limpio y con iconos, sin depender de servicios en la nube ni de integraciones frágiles. La pieza que marca la diferencia es el pequeño script intermediario: mantiene qBittorrent haciendo lo mínimo (llamar a un programa con argumentos) y deja toda la lógica de formato en Home Assistant, que es donde mejor se controla. Un patrón que, por cierto, sirve para muchas otras integraciones de este estilo.

Y con esto y un bizcocho …..

Sensor Lluvia ZG-223Z e integración en Home Assistant

Estos días decidí montar un sensor de lluvia que compre hace unas semanas y ponerlo en el balcón para que avisase cuando estaba lloviendo . Este es el enlace donde lo compre.

La parte superior del sensor tiene un led de estado y el medidor de luz. Tiene una forma inclinada la parte del sensor para evitar que las gotas se acumulen en su superficie

Para ponerlo en marcha quitaremos el papel que evita la descarga de la bateria

Dentro del compartimento de la bateria encontraremos un botón que nos sirve con una pulsación corta para enviar los datos actuales y con una pulsación de ocho o mas segundos para ponerlo en modo emparejamiento

Zigbee2MQTT lo reconoce de forma nativa tal como pone en este documento , https://www.zigbee2mqtt.io/devices/ZG-223Z.html

Al emparejarlo empezara la entrevista con el dispositivo

Da este error de que no esta soportado , pero como veremos mas adelante lo soporta sin poblemas

Una vez cambiado el nombre del dispositivo veremos todas sus especificaciones

Este seria el JSON que nos llegaria via MQTT , la señal de alarma de lluvia en este caso seria la alarm_1

{
    "battery": 100,
    "device": {
        "applicationVersion": 147,
        "dateCode": "25102025",
        "friendlyName": "sensor_lluvia",
        "hardwareVersion": 1,
        "ieeeAddr": "0xa4c1388d75d391c3",
        "manufacturerID": 4742,
        "manufacturerName": "HOBEIAN",
        "model": "ZG-223Z",
        "networkAddress": 26845,
        "powerSource": "Battery",
        "softwareBuildID": "0125102025",
        "stackVersion": 2,
        "type": "EndDevice",
        "zclVersion": 3
    },
    "illuminance": 33573,
    "illuminance_lux": 2276,
    "last_seen": "2026-01-05T14:10:57+01:00",
    "linkquality": 43,
    "alarm_1": false,
    "alarm_2": false,
    "battery_low": false,
    "tamper": false
}

En nuestra sección sensor: crearemos estos sensores

### SENSOR LLUVIA
      
    - state_topic: "zigbee2mqtt/sensor_lluvia"
      availability_topic: "zigbee2mqtt/bridge/state"
      unit_of_measurement: "%"
      icon: "mdi:battery"
      device_class: "battery"
      value_template: "{{ value_json.battery }}"
      expire_after: 86400
      force_update: true
      name: "sensor_lluvia_bateria"
  
    - state_topic: "zigbee2mqtt/sensor_lluvia"
      availability_topic: "zigbee2mqtt/bridge/state"
      icon: "mdi:signal"
      unit_of_measurement: "lqi"
      value_template: "{{ value_json.linkquality }}"
      name: "sensor_lluvia_estado"  
  
    - state_topic: "zigbee2mqtt/sensor_lluvia"
      availability_topic: "zigbee2mqtt/bridge/state"
      icon: "mdi:calendar-clock"
      value_template: "{{ value_json.last_seen }}"
      name: "sensor_lluvia_ultima_conexion" 

En nuestra sección binary_sensor: crearemos estos sensores

### SENSOR LLUVIA

    - state_topic: "zigbee2mqtt/sensor_lluvia"
      availability_topic: "zigbee2mqtt/sensor_lluvia/availability"  
      payload_on: true
      payload_off: false
      value_template: "{{ value_json.alarm_1 }}"
      device_class: "moisture"   
      name: sensor_lluvia_evento

    - state_topic: "zigbee2mqtt/sensor_lluvia"
      availability_topic: "zigbee2mqtt/sensor_lluvia/availability"  
      payload_on: true
      payload_off: false
      value_template: "{{ value_json.battery_low }}"
      device_class: "battery"   
      name: sensor_lluvia_bateria_baja_evento  
      
    - state_topic: "zigbee2mqtt/sensor_lluvia/availability"       
      availability_topic:  "zigbee2mqtt/sensor_lluvia/availability"     
      name: sensor_lluvia_disponibilidad      
      device_class: "connectivity"      
      payload_on: "online"
      payload_off: "offline"       

Los ponemos mas bonitos en nuestro customize.yaml

binary_sensor.sensor_lluvia_evento:  
  friendly_name: Sensor lluvia
  icon: mdi:weather-lightning-rainy
binary_sensor.sensor_lluvia_bateria_baja_evento:
  friendly_name: Sensor lluvia
sensor.sensor_lluvia_bateria:
  friendly_name: Sensor lluvia
sensor.sensor_lluvia_estado:
  friendly_name: Sensor lluvia
sensor.sensor_lluvia_ultima_conexion:
  friendly_name: Sensor lluvia
binary_sensor.sensor_lluvia_disponibilidad: 
  friendly_name: Sensor lluvia

Y ya empezamos con las automatizaciones , en este caso la principal que si el sensor detecta lluvia durante mas de diez segundos nos avisara por Telegram y visualmente encendiendo y apagando los cuatro Gateways en color azul , y si es una hora prudencial nos dará también un aviso sonoro por los Google home

##################################
### Automatizaciones sensor lluvia
##################################

# Notificación cuando empieza a llover (con delay y límite de frecuencia)
- alias: alerta_de_lluvia_telegram
  id: alerta_de_lluvia_telegram
  trigger:
    platform: state
    entity_id: binary_sensor.sensor_lluvia_evento
    to: "on"
    for:
      seconds: 10  # Debe estar 10 segundos en ON
  condition:
    - condition: template
      value_template: >
        {% set last = state_attr('automation.alerta_de_lluvia_telegram', 'last_triggered') %}
        {{ last == none or (now() - last).total_seconds() > 3600 }}
  action:
    - service: notify.notif_telegram_bot
      data:
        title: "🌧️ *¡Está lloviendo!*"
        message: >
          ☔ Ha comenzado a llover.
          
          🪟 Revisa ventanas y toldos

    # TTS solo entre las 8:00 y las 23:00
    - choose:
        - conditions:
            - condition: time
              after: "08:00:00"
              before: "23:00:00"
          sequence:
            - service: media_player.volume_set
              continue_on_error: true
              entity_id: 
                - media_player.lenovo_smart_clock
                - media_player.googlehome2670
                - media_player.lenovo_lenovo_matrimonio
              data:
                volume_level: 0.6
          
            - delay: 00:00:03 
          
            - service: tts.google_translate_say
              data:
                language: "es-es"	
                entity_id: 
                  - media_player.lenovo_smart_clock
                  - media_player.lenovo_lenovo_matrimonio
                  - media_player.googlehome2670
                message: 'Ha comenzado a llover , Revisa ventanas y toldos '  
          
            - delay: 00:00:03 
    
    - repeat:
        sequence:        
          # Luces en azul 25 veces 
          - service: light.turn_on
            target:
              entity_id:
                - light.gateway_light_7c49eb1d1d0a
                - light.gateway_light_286c07f0e736
                - light.gateway_light_286c07f0b574
                - light.gateway_light_04cf8cf69f4f
            data:
              brightness: 255
              rgb_color: [4, 29, 247]     
                             
          - delay: '00:00:02'              
             
          - service: light.turn_off
            target:
              entity_id:
                - light.gateway_light_7c49eb1d1d0a
                - light.gateway_light_286c07f0e736           
                - light.gateway_light_286c07f0b574   
                - light.gateway_light_04cf8cf69f4f            
                         
          - delay: '00:00:01'  
          
        until: '{{ repeat.index >= 25 }}'
        
  mode: single
  max_exceeded: silent

Esta otra por ejemplo nos avisara en caso de que llueva y exista alguna puerta o ventana exterior abierta

# Alerta de lluvia con ventanas abiertas (URGENTE)
- alias: lluvia_y_ventanas_abiertas_telegram
  id: lluvia_y_ventanas_abiertas_telegram
  trigger:
    platform: state
    entity_id: binary_sensor.sensor_lluvia_evento
    to: "on"
    for:
      seconds: 30
  condition:
    - condition: state
      entity_id: binary_sensor.todas_puertas_ventanas_cerradas
      state: "off"
    - condition: template
      value_template: >
        {% set last = state_attr('automation.lluvia_y_ventanas_abiertas_telegram', 'last_triggered') %}
        {{ last == none or (now() - last).total_seconds() > 3600 }}
  action:
    - service: notify.notif_telegram_bot
      data:
        title: "🚨 *¡ATENCIÓN URGENTE!*"
        message: >
          ⚠️ *Está lloviendo y tienes ventanas o puertas abiertas*
          
          ⏰ Hora: {{ now().strftime('%H:%M') }}
          
          ¡Ciérralas cuanto antes!
          
    # TTS solo entre las 8:00 y las 23:00
    - choose:
        - conditions:
            - condition: time
              after: "08:00:00"
              before: "23:00:00"
          sequence:
            - service: media_player.volume_set
              continue_on_error: true
              entity_id: 
                - media_player.lenovo_smart_clock
                - media_player.googlehome2670
                - media_player.lenovo_lenovo_matrimonio
              data:
                volume_level: 0.6
          
            - delay: 00:00:03 
          
            - service: tts.google_translate_say
              data:
                language: "es-es"	
                entity_id: 
                  - media_player.lenovo_smart_clock
                  - media_player.lenovo_lenovo_matrimonio
                  - media_player.googlehome2670
                message: 'Está lloviendo y tienes ventanas o puertas abiertas , Ciérralas cuanto antes'  
          
            - delay: 00:00:03 

   
  mode: single
  max_exceeded: silent

Y esta otra nos avisara cuando haya dejado de llover hace cinco minutos

# Ha dejado de llover
- alias: ha_dejado_de_llover_telegram
  id: ha_dejado_de_llover_telegram
  trigger:
    platform: state
    entity_id: binary_sensor.sensor_lluvia_evento
    to: "off"
    for:
      minutes: 5
  condition:
    - condition: template
      value_template: >
        {% set last = state_attr('automation.ha_dejado_de_llover_telegram', 'last_triggered') %}
        {{ last == none or (now() - last).total_seconds() > 3600 }}
  action:
    - service: notify.notif_telegram_bot
      data:
        title: "☀️ *Ya no llueve*"
        message: >
          🌤️ Ha dejado de llover hace 5 minutos

    # TTS solo entre las 8:00 y las 23:00
    - choose:
        - conditions:
            - condition: time
              after: "08:00:00"
              before: "23:00:00"
          sequence:
            - service: media_player.volume_set
              continue_on_error: trueº
              entity_id: 
                - media_player.lenovo_smart_clock
                - media_player.googlehome2670
                - media_player.lenovo_lenovo_matrimonio
              data:
                volume_level: 0.6
          
            - delay: 00:00:03 
          
            - service: tts.google_translate_say
              data:
                language: "es-es"	
                entity_id: 
                  - media_player.lenovo_smart_clock
                  - media_player.lenovo_lenovo_matrimonio
                  - media_player.googlehome2670
                message: 'Ha dejado de llover hace 5 minutos'  
          
            - delay: 00:00:03 


  mode: single
  max_exceeded: silent

Ahora si queremos le añadimos unas estadisiticas

#####################################################
### SENSOR LLUVIA
#####################################################    

  - platform: history_stats
    name: Eventos Lluvia Hoy
    entity_id: binary_sensor.sensor_lluvia_evento
    state: "on"
    type: count
    start: "{{ now().replace(hour=0, minute=0, second=0) }}"
    end: "{{ now() }}"
    
  - platform: history_stats
    name: Eventos Lluvia Esta Semana
    entity_id: binary_sensor.sensor_lluvia_evento
    state: "on"
    type: count
    start: "{{ as_timestamp(now().replace(hour=0, minute=0, second=0)) - now().weekday() * 86400 }}"
    end: "{{ now() }}"
    
  - platform: history_stats
    name: Eventos Lluvia Este Mes
    entity_id: binary_sensor.sensor_lluvia_evento
    state: "on"
    type: count
    start: "{{ now().replace(day=1, hour=0, minute=0, second=0) }}"
    end: "{{ now() }}"

Y lo añadimos a nuestro panel lovelace

      - type: entities
        title: Estadísticas de Lluvia
        entities:
          - entity: sensor.eventos_lluvia_hoy
            name: Eventos Hoy
          - entity: sensor.eventos_lluvia_esta_semana
            name: Esta Semana
          - entity: sensor.eventos_lluvia_este_mes
            name: Este Mes        
        
      - type: history-graph
        title: Historial Sensor Lluvia
        entities:
          - entity: binary_sensor.sensor_lluvia_evento
        hours_to_show: 720  # 30 días
        refresh_interval: 60

Quedando visualmente tal como este

Y con esto y un bizcocho ……

Y AL FIN LLOVIO

Después de unos días de instalarlo empezó a llover y el sensor hizo su trabajo