Questa guida è il seguito di Google TV in Home Assistant: Android TV Remote, Google Cast e ADB insieme. Lì la TV entra in Home Assistant con tre integrazioni, ognuna per quello che sa fare; qui do per scontato che ci siano tutte e tre e le porto sul numero WhatsApp di casa. Chi è autorizzato scrive un messaggio che comincia con tv, Home Assistant esegue e risponde.

Il punto di partenza è un gateway WhatsApp che passa i messaggi a Home Assistant con un webhook e un elenco di mittenti autorizzati, con i ruoli Admin e Utente.

📋 I comandi e cosa rispondono

MessaggioCosa fa
tvStato: accesa o spenta, app in uso, volume
tv accendi · tv spegniAccende o spegne la TV
tv volume 20Volume a un valore preciso, da 0 a 100
tv volume su · tv volume giuVolume a scatti
tv mutoToglie o rimette l'audio
tv apri netflixApre un'app con il suo nome semplice
tv appElenco delle app che si possono aprire
tv schermoFoto dello schermo, come immagine WhatsApp
tv di la cena è prontaLa TV pronuncia la frase a voce
tv aiutoElenco dei comandi

Le risposte hanno lo stesso formato degli altri messaggi di casa, quello delle notifiche WhatsApp formattate: titolo in grassetto, una riga di separazione, le informazioni con le loro emoji e, se serve, una nota in corsivo.

tv20:41
📺 TV ━━━━━━━━━━━━━━ 🟢 Accesa 📱 App: YouTube 🔊 Volume: 20 Scrivi tv aiuto per vedere i comandi20:41
tv apri prime20:42
📺 APP APERTA ━━━━━━━━━━━━━━ ▶️ Prime Video Scrivi tv app per vedere tutte le app20:42

Esempio: lo stato della TV e l'apertura di un'app con un sinonimo

🧱 L'architettura

  1. L'automazione che riceve il webhook del gateway riconosce i messaggi che iniziano con tv, dopo i controlli sul mittente che fa già per tutti gli altri comandi.
  2. Avvia uno script dedicato, script.tv_comando, con due campi: il testo del messaggio e la chat a cui rispondere.
  3. Lo script normalizza il testo: minuscole, niente accenti, niente punteggiatura. Così «TV Volume Giù!» e «tv volume giu» sono lo stesso comando.
  4. Dalla seconda parola ricava l'azione (volume, apri, schermo...), e dalle parole che seguono il resto (20, netflix...). Se c'è solo tv, l'azione è lo stato.
  5. Per l'azione di la frase si prende invece dal testo originale: altrimenti la TV direbbe «la cena e pronta» senza accento, e un nome proprio perderebbe la maiuscola.

⏱️ Perché script.turn_on e non una chiamata diretta

  • Se l'automazione chiama lo script direttamente (action: script.tv_comando), aspetta che finisca. Accendere la TV o fare uno screenshot può durare un minuto, e per tutto quel tempo il webhook resta in attesa.
  • Con script.turn_on lo script parte per conto suo e l'automazione finisce subito: il webhook risponde al gateway senza aspettare.
  • La risposta su WhatsApp la manda lo script stesso, quando ha finito, alla chat che ha ricevuto nel campo.

📺 Le app con nomi semplici

Nessuno vuole scrivere com.amazon.amazonvideo.livingroom per guardare un film. Lo script ha due mappe:

  • nome → pacchetto: netflix, youtube, prime video, raiplay... ognuno con il suo pacchetto. I pacchetti sono quelli verificati nella guida precedente.
  • sinonimi: prime → prime video, rai → raiplay, e così via, per i nomi che vengono spontanei.

La ricerca va in tre passi, dal più preciso al più largo:

  1. il nome esatto: netflix trova Netflix;
  2. l'inizio del nome: disney trova disney plus;
  3. la parola contenuta nel nome: video trova prime video.

Se nessuno dei tre trova qualcosa, la risposta è l'elenco delle app disponibili, lo stesso di tv app: chi ha sbagliato nome vede subito come scriverlo.

🔐 Permessi per categoria

Ogni persona autorizzata ha un ruolo. Gli Admin usano tutti i comandi; gli Utenti hanno una casella per ogni categoria, e tra queste ora c'è anche 📺 TV. Un Utente senza la categoria TV che scrive tv riceve «comando non disponibile», e il suo aiuto mostra solo i comandi che può usare: quelli della TV non compaiono.

tv spegni21:15
🔒 COMANDO NON DISPONIBILE ━━━━━━━━━━━━━━ Il tuo profilo non comprende i comandi della TV. Scrivi aiuto per vedere quelli che puoi usare21:15

Esempio: un Utente senza la categoria TV

Aggiungere una categoria nuova, come ho fatto per la TV, richiede tre passaggi:

  1. Un interruttore per persona: un input_boolean «TV» accanto a quelli delle altre categorie, nella scheda di ogni persona.
  2. L'elenco delle categorie: la categoria va aggiunta nel punto da cui leggono il riepilogo delle persone e il comando aiuto, con la sua icona e le righe di aiuto.
  3. L'abbinamento comando → categoria: il comando tv va collegato alla categoria TV, così il controllo dei permessi sa cosa verificare.

🪤 Le trappole dei template

Lo script è quasi tutto template Jinja, e ci sono tre trappole da conoscere.

1. Il testo che diventa un numero

Quando un template sta nelle variables, Home Assistant converte il risultato nel suo tipo «naturale». Se il resto del comando è 20, la variabile non contiene il testo «20» ma il numero 20. A quel punto .split() o .startswith() su quella variabile danno errore e lo script si ferma, senza rispondere.

# Sbagliato: con "tv volume 20" resto è il numero 20 e isdigit() fallisce
- conditions: "{{ resto.isdigit() }}"

# Giusto: ogni testo passa da | string prima di usarne i metodi
- conditions: "{{ (resto | string).isdigit() }}"

La regola che seguo: ogni variabile che contiene testo passa da | string nel punto in cui la uso, non solo dove la creo. Riassegnarla in un'altra variables la riconvertirebbe di nuovo.

2. Il codice di uscita che dice sempre «tutto bene»

Lo screenshot passa da uno script esterno chiamato con shell_command. Lo script esce sempre con codice 0 e scrive l'esito vero in un JSON sullo stdout. Controllare returncode quindi non serve: è 0 anche quando la cattura è fallita. È stato il motivo di uno screenshot «riuscito» che arrivava senza foto.

- action: shell_command.tv_screenshot
  response_variable: esito
  continue_on_error: true
- variables:
    dati: "{{ esito.stdout | from_json if esito is defined and (esito.stdout | default('') | trim) != '' else {} }}"
    riuscito: "{{ dati.ok | default(false) }}"

Da qui in poi la decisione si prende su riuscito, cioè sul campo ok del JSON.

3. La foto di prima

L'immagine dello schermo si salva sempre con lo stesso nome, sovrascrivendo la precedente. Se una cattura fallisce, il file c'è comunque: è quello della volta prima. Per non mandare una foto vecchia spacciandola per nuova, lo script controlla l'età del file e lo rifiuta se ha più di 120 secondi:

import json, os, sys, time

foto = sys.argv[1]
if not os.path.exists(foto) or time.time() - os.path.getmtime(foto) > 120:
    print(json.dumps({"ok": False, "errore": "foto non aggiornata"}))
    sys.exit(0)
print(json.dumps({"ok": True, "file": foto}))

È lo stesso controllo che uso per le immagini del robot Dreame su WhatsApp: meglio un messaggio senza foto che una foto di un altro momento.

🛡️ La robustezza

  • accendi: prima il pacchetto Wake-on-LAN, poi un wait_template con timeout che aspetta che Android TV Remote torni raggiungibile, poi media_player.turn_on. La risposta parte solo dopo aver verificato lo stato: «accesa» se lo è davvero, altrimenti un messaggio che dice che la TV non ha risposto.
  • schermo e apri: se l'entità ADB è caduta, prima del comando si ricollega con homeassistant.update_entity in un ciclo repeat/until, al massimo 5 tentativi.
  • Nessun silenzio: ogni azione verso la TV ha continue_on_error: true. Se un'azione fallisce lo script non si ferma, arriva alla risposta e l'utente riceve un messaggio chiaro invece di niente.
  • schermo con la TV spenta: risposta immediata, senza provarci. Non c'è niente da fotografare e i tentativi su ADB andrebbero solo in timeout.

🧩 Un esempio ridotto ma completo

Lo script con tre azioni: stato, volume e apri. Le entità sono d'esempio (media_player.tv_remote, media_player.tv_cast, media_player.tv_adb), e script.whatsapp_rispondi sta per lo script con cui già rispondi a una chat. Nel testo delle risposte * è il grassetto di WhatsApp e _ il corsivo.

script:
  tv_comando:
    mode: queued
    fields:
      testo:
        description: Il messaggio ricevuto
      chat:
        description: La chat a cui rispondere
    sequence:
      - variables:
          originale: "{{ testo | string | trim }}"
          pulito: >-
            {{ originale | lower
               | replace('à', 'a') | replace('è', 'e') | replace('é', 'e')
               | replace('ì', 'i') | replace('ò', 'o') | replace('ù', 'u')
               | regex_replace('[^a-z0-9 ]', ' ') | regex_replace(' +', ' ') | trim }}
          parole: "{{ (pulito | string).split(' ')[1:] }}"
          azione: "{{ parole[0] if parole | count > 0 else 'stato' }}"
          resto: "{{ parole[1:] | join(' ') }}"
          app_mappa:
            netflix: com.netflix.ninja
            youtube: com.google.android.youtube.tv
            prime video: com.amazon.amazonvideo.livingroom
            disney plus: com.disney.disneyplus
            raiplay: it.rainet.androidtv
          sinonimi:
            prime: prime video
            amazon: prime video
            rai: raiplay
      - choose:
          # tv: stato
          - conditions: "{{ azione == 'stato' }}"
            sequence:
              - variables:
                  st: "{{ states('media_player.tv_remote') }}"
                  accesa: "{{ st not in ['off', 'unavailable', 'unknown'] }}"
                  app: "{{ state_attr('media_player.tv_remote', 'app_name') or 'nessuna' }}"
                  vol: "{{ ((state_attr('media_player.tv_cast', 'volume_level') or 0) * 100) | round(0) | int }}"
              - action: script.whatsapp_rispondi
                data:
                  chat: "{{ chat }}"
                  testo: |-
                    {% if accesa %}*📺 TV*
                    ━━━━━━━━━━━━━━
                    🟢 Accesa
                    📱 App: {{ app }}
                    🔊 Volume: {{ vol }}{% else %}*📺 TV*
                    ━━━━━━━━━━━━━━
                    ⚫ Spenta{% endif %}

                    _Scrivi tv aiuto per vedere i comandi_

          # tv volume 20 / su / giu
          - conditions: "{{ azione == 'volume' }}"
            sequence:
              - choose:
                  - conditions: "{{ (resto | string).isdigit() }}"
                    sequence:
                      - action: media_player.volume_set
                        continue_on_error: true
                        target:
                          entity_id: media_player.tv_cast
                        data:
                          volume_level: "{{ ([(resto | string) | int, 100] | min) / 100 }}"
                  - conditions: "{{ (resto | string) in ['su', 'giu'] }}"
                    sequence:
                      - action: "{{ 'media_player.volume_up' if (resto | string) == 'su' else 'media_player.volume_down' }}"
                        continue_on_error: true
                        target:
                          entity_id: media_player.tv_remote
              - action: script.whatsapp_rispondi
                data:
                  chat: "{{ chat }}"
                  testo: "*🔊 VOLUME*\n━━━━━━━━━━━━━━\nFatto: {{ resto }}"

          # tv apri netflix
          - conditions: "{{ azione == 'apri' }}"
            sequence:
              - variables:
                  cerca: "{{ sinonimi.get(resto | string, resto | string) }}"
                  nome: >-
                    {% set c = cerca | string %}
                    {% set nomi = app_mappa.keys() | list %}
                    {% if c %}{{ ((nomi | select('eq', c) | list)
                        + (nomi | select('match', c) | list)
                        + (nomi | select('search', c) | list) + [''])[0] }}{% endif %}
              - if: "{{ (nome | string | trim) == '' }}"
                then:
                  - action: script.whatsapp_rispondi
                    data:
                      chat: "{{ chat }}"
                      testo: "*📺 APP DISPONIBILI*\n━━━━━━━━━━━━━━\n{{ app_mappa.keys() | join(', ') }}"
                  - stop: App non trovata
              - if: "{{ is_state('media_player.tv_adb', 'unavailable') }}"
                then:
                  - repeat:
                      sequence:
                        - action: homeassistant.update_entity
                          continue_on_error: true
                          target:
                            entity_id: media_player.tv_adb
                        - delay: "00:00:05"
                      until: "{{ not is_state('media_player.tv_adb', 'unavailable') or repeat.index >= 5 }}"
              - action: androidtv.adb_command
                continue_on_error: true
                target:
                  entity_id: media_player.tv_adb
                data:
                  command: "monkey -p {{ app_mappa[nome | string | trim] }} -c android.intent.category.LEANBACK_LAUNCHER 1"
              - action: script.whatsapp_rispondi
                data:
                  chat: "{{ chat }}"
                  testo: "*📺 APP APERTA*\n━━━━━━━━━━━━━━\n▶️ {{ nome | string | trim }}"

E il ramo nell'automazione del webhook, dentro il choose che già smista gli altri comandi. testo, chat e permesso_tv sono le variabili che l'automazione ricava dal messaggio e dal controllo dei permessi:

- conditions: "{{ (testo | string | lower | trim).split(' ')[0] == 'tv' }}"
  sequence:
    - if: "{{ not permesso_tv }}"
      then:
        - action: script.whatsapp_rispondi
          data:
            chat: "{{ chat }}"
            testo: "*🔒 COMANDO NON DISPONIBILE*\n━━━━━━━━━━━━━━\nIl tuo profilo non comprende i comandi della TV."
      else:
        - action: script.turn_on
          target:
            entity_id: script.tv_comando
          data:
            variables:
              testo: "{{ testo }}"
              chat: "{{ chat }}"

Gli altri comandi (accendi, spegni, muto, schermo, di, app, aiuto) sono altri rami dello stesso choose, costruiti allo stesso modo. Per la voce l'azione è tts.speak verso il lettore di Google Cast, come nella guida precedente, con la frase presa da originale.

🧪 Come provarlo

Dal numero di un Admin, in quest'ordine:

  1. tv aiuto: deve arrivare l'elenco dei comandi.
  2. tv: lo stato, con app e volume.
  3. tv volume 20, poi tv volume su e tv muto.
  4. tv apri netflix, poi tv apri prime (sinonimo) e tv apri pippo (deve rispondere con l'elenco).
  5. tv di la cena è pronta: la TV deve dirlo con l'accento giusto.
  6. tv schermo: arriva la foto dello schermo.
  7. tv spegni, poi tv schermo: risposta immediata che la TV è spenta.
  8. tv accendi, poi subito tv schermo: è la prova della riconnessione di ADB, che dopo una riaccensione può essere caduta.

Poi dal numero di un Utente senza la categoria TV: tv deve rispondere «comando non disponibile», e aiuto non deve mostrare i comandi della TV.

💡 Idee per estenderlo

  • Canali TV: tv canale 5, con media_player.play_media di tipo channel su Android TV Remote, che nella documentazione è tra i tipi supportati.
  • Telecomando con i tasti: tv tasto ok, tv tasto indietro, con l'azione remote.send_command dell'entità telecomando di Android TV Remote.
  • Avvisi quando la TV si accende: un'automazione sullo stato di Android TV Remote che manda un messaggio, per esempio a orari in cui la TV dovrebbe essere spenta.