In casa ho una vista Smart Life nella dashboard di Home Assistant: due termostati (caldaia e condizionatore), un termometro, luci e interruttori, prese con misura dei consumi, alcune scene e un sensore gas. Sono collegati con l'integrazione ufficiale Tuya, come racconto in Smart Life in Home Assistant. Volevo comandare tutto questo anche dal numero WhatsApp di casa, scrivendo come scrivo a una persona. E volevo decidere, persona per persona, chi vede e chi comanda cosa.

Il risultato, in pratica, è questo:

casa caldaia 2118:02
🔥 CALDAIA ━━━━━━━━━━━━━━ Caldaia: 🟢 caldo · ambiente 19.5 °C → 21 °C18:02
casa spegni luci18:03
💡 LUCI SPENTE ━━━━━━━━━━━━━━ Scrivania: ⚫ spenta Soggiorno: ⚫ spenta Ingresso: ⚠️ non raggiungibile18:03

Esempio: una temperatura impostata e le luci spente, con un dispositivo che non risponde

Il gateway e il controllo dei mittenti c'erano già: li descrivo in mittenti autorizzati e in ruoli Admin e Utente con categorie. L'Admin può fare tutto, l'Utente solo le categorie che gli accendo (sistema, rete, telecamere, robot, spesa, TV). Qui si aggiunge Smart Life, con un livello in più: le sezioni.

🧱 L'architettura

💬 WhatsAppil messaggio arriva al gateway
webhook
→
🔐 Permessichi scrive e quali sezioni ha
testo, chat, sezioni
→
🏠 script.casa_comandocapisce ed esegue
stato riletto
→
📱 Rispostauna riga per dispositivo

Il percorso di un comando Smart Life

Tutto il lavoro sta in un solo script, casa_comando, con tre campi: il testo ricevuto, la chat a cui rispondere e le sezioni che quel mittente può usare. L'automazione del webhook non sa niente di luci o termostati: controlla chi scrive, calcola le sezioni e passa la palla.

📋 Un dispositivo, una riga

I dispositivi stanno in una variabile dello script, uno per riga: l'entità, il nome da mostrare, la sezione (clima, luci, prese, scene, gas), il tipo e gli alias, cioè i modi in cui posso scriverne il nome. Le prese hanno anche il sensore di potenza.

dispositivi:
  - {id: climate.caldaia, nome: Caldaia, sez: clima, tipo: termostato, alias: [caldaia, riscaldamento]}
  - {id: climate.argo, nome: Argo, sez: clima, tipo: termostato, alias: [argo, condizionatore]}
  - {id: sensor.termometro, nome: Termometro, sez: clima, tipo: sensore, alias: [termometro]}
  - {id: light.scrivania, nome: Scrivania, sez: luci, tipo: luce, alias: [luce scrivania, scrivania]}
  - {id: switch.presa_2, nome: Presa 2, sez: prese, tipo: presa, alias: [presa 2], potenza: sensor.presa_2_potenza}
  - {id: switch.presa_deposito, nome: Deposito, sez: prese, tipo: presa, alias: [presa deposito, deposito], potenza: sensor.presa_deposito_potenza}
  - {id: scene.bagno, nome: Bagno, sez: scene, tipo: scena, alias: [scena bagno, bagno]}
  - {id: scene.accendi_fuori, nome: Accendi fuori, sez: scene, tipo: scena, alias: [accendi fuori]}
  - {id: binary_sensor.sensore_gas, nome: Sensore gas, sez: gas, tipo: gas, alias: [sensore gas]}

Il vantaggio è che una lista sola guida tutto: il riconoscimento dei comandi, gli elenchi, la panoramica e i permessi. Un dispositivo nuovo è una riga in più, e compare da solo ovunque.

🗣️ Capire il linguaggio naturale

Il riconoscimento è tutto template Jinja, nelle variables dello script.

Normalizzare il testo

Prima di tutto il testo diventa prevedibile: minuscolo, senza accenti, senza punteggiatura, spazi singoli, e senza il casa iniziale. Così «Casa, accendi la Scrivania!» e «casa accendi la scrivania» sono la stessa frase.

pulito: >-
  {{ testo | string | lower
     | replace('à', 'a') | replace('è', 'e') | replace('é', 'e')
     | replace('ì', 'i') | replace('ò', 'o') | replace('ù', 'u')
     | regex_replace('[^a-z0-9, ]', ' ') | regex_replace(' +', ' ') | trim
     | regex_replace('^casa(\s|$)', '') | trim }}

La virgola resta di proposito, per i decimali: 21,5 deve arrivare intero al passaggio dei numeri.

Le parole che contano

Dalle parole si ricavano cinque cose:

  • il verbo: accendi o on, spegni o off;
  • il numero: temperatura o luminosità, anche con la virgola;
  • la modalità del condizionatore: freddo, caldo, deumidifica, ventola, auto;
  • la sezione nominata: luci, prese, scene, clima, gas;
  • «tutte», che serve per le prese (più sotto il perché).
parole: "{{ (pulito | string).split(' ') }}"
verbo: >-
  {% if parole | select('in', ['accendi', 'on']) | list %}on
  {%- elif parole | select('in', ['spegni', 'off']) | list %}off{% endif %}
numero: "{{ (parole | select('match', '^[0-9]+([.,][0-9]+)?$') | list + [''])[0] | replace(',', '.') }}"
modi: {freddo: cool, caldo: heat, deumidifica: dry, ventola: fan_only, auto: auto}
modo: "{{ (parole | select('in', modi.keys() | list) | list + [''])[0] }}"
sezione: >-
  {% set m = {'luci': 'luci', 'luce': 'luci', 'prese': 'prese', 'scene': 'scene',
              'clima': 'clima', 'gas': 'gas'} %}
  {% set ns = namespace(s='') %}
  {% for p in parole if p in m and not ns.s %}{% set ns.s = m[p] %}{% endfor %}
  {{ ns.s }}
tutte: "{{ 'tutte' in parole or 'tutti' in parole }}"

Trovare il dispositivo in tre passaggi

La parte più delicata è capire di quale dispositivo si parla. La ricerca fa tre passaggi, e si ferma al primo che trova qualcosa:

  1. il testo intero: così accendi fuori trova la scena che si chiama proprio «Accendi fuori», invece di accendere qualcosa che si chiama «fuori»;
  2. il testo senza le parole di comando e senza numeri: luce scrivania 50 diventa luce scrivania, e il 50 resta come valore;
  3. il testo con i numeri: così presa 2 trova la presa 2, e il 2 non viene preso come valore.

In ogni passaggio provo prima il nome esatto, poi l'inizio, poi il contenuto, come per le app nella Google TV da WhatsApp.

trovato: >-
  {% set comandi = ['accendi', 'on', 'spegni', 'off', 'tutte', 'tutti', 'le', 'la', 'il']
                   + (modi.keys() | list) %}
  {% set senza = parole | reject('in', comandi) | reject('match', '^[0-9]') | join(' ') %}
  {% set con_num = parole | reject('in', comandi) | join(' ') %}
  {% set ns = namespace(id='') %}
  {% for q in [pulito, senza, con_num] if q and not ns.id %}
    {% for prova in ['eq', 'inizio', 'contiene'] if not ns.id %}
      {% set ok = dispositivi | selectattr('sez', 'in', sezioni) | selectattr('alias', 'defined')
         | select('trova_alias', q, prova) | list %}
      {# nell'esempio un passaggio vale solo se trova un dispositivo solo #}
      {% if ok | count == 1 %}{% set ns.id = ok[0].id %}{% endif %}
    {% endfor %}
  {% endfor %}
  {{ ns.id }}

Qui trova_alias sta per il confronto fra gli alias e la frase: in Jinja si scrive con tre cicli su alias, usando ==, startswith e in. L'ho compresso per leggibilità. Nota il filtro sulle sezioni: un dispositivo di una sezione che il mittente non ha non viene nemmeno cercato.

MessaggioCosa capisce
casa caldaia 21Caldaia a 21 °C (se è spenta, prima la accende)
casa argo freddoCondizionatore in modalità raffreddamento
casa luce scrivania 50Luce della scrivania accesa al 50%
casa spegni luciSpegne tutte le luci
casa presa 2 onAccende la presa 2 (il 2 fa parte del nome)
casa spegni tutte le preseSpegne tutte le prese
casa scena bagnoAttiva la scena «Bagno»
casa accendi fuoriAttiva la scena «Accendi fuori»
casaPanoramica di tutte le sezioni permesse
casa banana«Non ho capito», con l'elenco dei comandi

⚙️ Le azioni, e la verifica dopo

Una volta trovato il dispositivo, il tipo decide l'azione:

  • termostati: climate.turn_on e turn_off, climate.set_temperature e climate.set_hvac_mode;
  • luci: light.turn_on con brightness_pct, quando c'è un numero;
  • interruttori e prese: homeassistant.turn_on e turn_off, che vanno bene per qualunque dominio;
  • scene: scene.turn_on.

La temperatura viene sempre limitata al minimo e al massimo che il termostato dichiara, e se il termostato è spento lo accendo prima di impostarla:

- if: "{{ is_state(trovato, 'off') }}"
  then:
    - action: climate.turn_on
      continue_on_error: true
      target: {entity_id: "{{ trovato }}"}
- action: climate.set_temperature
  continue_on_error: true
  target: {entity_id: "{{ trovato }}"}
  data:
    temperature: >-
      {% set t = numero | float(0) %}
      {{ [[t, state_attr(trovato, 'min_temp') | float(5)] | max,
          state_attr(trovato, 'max_temp') | float(35)] | min }}

Sulle sezioni intere ho fatto una scelta diversa per luci e prese. spegni luci agisce su tutte le luci, perché al massimo resto al buio. Per le prese serve dire tutte: una presa può alimentare un frigorifero, e spegnerla per un messaggio scritto di fretta non è un errore che voglio rendere facile. Senza «tutte» la risposta spiega come scriverlo.

⏳ Tuya passa dal cloud: aspettare, poi rileggere

  • Dopo l'azione lo script aspetta alcuni secondi, perché il comando va al cloud Tuya e torna indietro.
  • Poi rilegge lo stato vero di ogni dispositivo toccato e risponde con quello, non con quello che si aspettava.
  • Se lo stato non è ancora cambiato, o il dispositivo non risponde, il messaggio lo dice. Una conferma che finge che sia andato tutto bene è peggio di nessuna conferma.
- delay: "00:00:05"
- variables:
    atteso: "{{ verbo if verbo in ['on', 'off'] else '' }}"
    riuscito: "{{ atteso == '' or is_state(trovato, atteso) }}"
    raggiungibile: "{{ states(trovato) not in ['unavailable', 'unknown'] }}"

📱 Risposte leggibili su WhatsApp

Ogni risposta ha lo stesso formato delle notifiche WhatsApp formattate: titolo in grassetto con emoji, separatore, una riga per dispositivo. Le righe dicono lo stato in modo che si capisca a colpo d'occhio: 🟢 accesa 100%, ⚫ spenta · 0.0 W, ⚠️ non raggiungibile, e per i termostati ambiente 21 °C → 23 °C, cioè temperatura misurata e obiettivo.

Perché panoramica, elenchi e conferme siano identici, il template della risposta contiene due macro Jinja: riga(dispositivo) scrive una riga, sezione(nome) scrive il titolo di una sezione e le righe dei suoi dispositivi.

testo: |-
  {%- macro riga(d) -%}
    {%- set s = states(d.id) -%}
    {%- if s in ['unavailable', 'unknown'] -%}{{ d.nome }}: ⚠️ non raggiungibile
    {%- elif d.tipo == 'termostato' -%}{{ d.nome }}: {{ '⚫ spento' if s == 'off' else '🟢 ' ~ {'heat': 'caldo', 'cool': 'freddo', 'dry': 'deumidifica', 'fan_only': 'ventola'}.get(s, s) }} · ambiente {{ state_attr(d.id, 'current_temperature') }} °C{{ ' → ' ~ state_attr(d.id, 'temperature') ~ ' °C' if s != 'off' }}
    {%- elif d.tipo == 'luce' -%}{{ d.nome }}: {{ '🟢 accesa ' ~ ((state_attr(d.id, 'brightness') | int(0)) / 2.55) | round(0) | int ~ '%' if s == 'on' else '⚫ spenta' }}
    {%- elif d.tipo == 'presa' -%}{{ d.nome }}: {{ '🟢 accesa' if s == 'on' else '⚫ spenta' }} · {{ states(d.potenza) }} W
    {%- elif d.tipo == 'gas' -%}{{ d.nome }}: {{ '🚨 gas rilevato' if s == 'on' else '✅ nessuna fuga' }}
    {%- else -%}{{ d.nome }}: {{ s }}{%- endif -%}
  {%- endmacro -%}
  {%- macro sezione(nome) -%}
  *{{ titoli[nome] }}*
  {% for d in dispositivi | selectattr('sez', 'eq', nome) %}{{ riga(d) }}
  {% endfor %}
  {%- endmacro -%}
  *🏠 CASA*
  ━━━━━━━━━━━━━━
  {% for n in ['clima', 'luci', 'prese', 'scene', 'gas'] if n in sezioni %}{{ sezione(n) }}
  {% endfor %}

Il ciclo finale scorre solo le sezioni permesse: è lo stesso punto in cui i permessi diventano visibili. La panoramica, con stati d'esempio, e una conferma:

casa21:10
🏠 CASA ━━━━━━━━━━━━━━ 🌡️ Clima Caldaia: 🟢 caldo · ambiente 20.5 °C → 21 °C Argo: ⚫ spento · ambiente 23 °C Termometro: 21.8 °C 💡 Luci Scrivania: ⚫ spenta Soggiorno: 🟢 accesa 60% 🔌 Prese Presa 2: 🟢 accesa · 84.3 W Deposito: ⚫ spenta · 0.0 W 🔥 Gas Sensore gas: ✅ nessuna fuga21:10
casa accendi scrivania21:11
💡 LUCE ACCESA ━━━━━━━━━━━━━━ Scrivania: 🟢 accesa 100%21:11

Esempio: la panoramica e la conferma dopo un comando, con stati inventati

🔐 Chi può fare cosa, dalla dashboard

Cinque interruttori per persona

Per ogni persona autorizzata ci sono cinque input_boolean, uno per sezione: clima, luci, prese, scene, gas. Nella scheda della persona compaiono solo se il ruolo è «Utente», perché all'Admin non servono: lui ha tutto. Lo fa la card entities con righe conditional e un'intestazione section.

type: entities
title: Persona 2
entities:
  - input_select.p2_ruolo
  - type: conditional
    conditions:
      - entity: input_select.p2_ruolo
        state: Utente
    row:
      type: section
      label: Smart Life
  - type: conditional
    conditions:
      - entity: input_select.p2_ruolo
        state: Utente
    row:
      entity: input_boolean.p2_sl_clima
  # ...una riga uguale per luci, prese, scene e gas
Persona 2
RuoloUtente ▾
Smart Life
🌡️ Clima
💡 Luci
🔌 Prese
🎬 Scene
🔥 Gas

Esempio: un Utente che comanda clima, luci e scene, ma non le prese

Un sensore che riassume

Un sensore template riassume per ogni persona ruolo, categorie e sezioni Smart Life. L'Admin ha sempre tutte le sezioni, senza guardare gli interruttori:

template:
  - sensor:
      - name: Permessi persona 2
        state: "{{ states('input_select.p2_ruolo') }}"
        attributes:
          smart_life: >-
            {% set admin = is_state('input_select.p2_ruolo', 'Admin') %}
            {% set ns = namespace(s=[]) %}
            {% for sez in ['clima', 'luci', 'prese', 'scene', 'gas'] %}
              {% if admin or is_state('input_boolean.p2_sl_' ~ sez, 'on') %}
                {% set ns.s = ns.s + [sez] %}
              {% endif %}
            {% endfor %}
            {{ ns.s | join(',') }}

Il webhook passa le sezioni

Quando arriva un messaggio che comincia con casa, l'automazione del webhook ha già riconosciuto il mittente. Legge le sue sezioni dal sensore e le passa allo script:

- variables:
    sezioni: "{{ (state_attr(sensore_mittente, 'smart_life') or '') | string }}"
- if: "{{ sezioni == '' }}"
  then:
    - stop: Nessuna sezione Smart Life per questo mittente
- action: script.turn_on
  target:
    entity_id: script.casa_comando
  data:
    variables:
      testo: "{{ testo }}"
      chat: "{{ chat }}"
      sezioni: "{{ sezioni.split(',') }}"

Da qui le regole sono tre:

  • una sezione spenta blocca il comando, con la risposta «🔒 COMANDO NON DISPONIBILE»;
  • la stessa sezione sparisce dalla panoramica e dall'aiuto, così nessuno vede comandi che non può usare;
  • con nessuna sezione accesa, la persona non vede proprio la categoria Smart Life.
casa spegni tutte le prese22:30
🔒 COMANDO NON DISPONIBILE ━━━━━━━━━━━━━━ Il tuo profilo non comprende le prese. Scrivi casa aiuto per vedere quello che puoi usare22:30

Esempio: un Utente senza la sezione prese

🪤 Intoppi e lezioni

💡 Quello che ho imparato

  • '\b' non è quello che sembra. In un template Jinja di Home Assistant la stringa '\b' diventa il carattere backspace: è un escape della stringa e non arriva mai alla regex. Il risultato era che «casa» non veniva tolto e nessun comando veniva riconosciuto. Meglio (\s|$), oppure raddoppiare la barra: '\\b'.
  • Il testo che diventa numero. Home Assistant converte il risultato dei template nelle variables nel suo tipo naturale: «21» diventa il numero 21. Nei confronti uso | string, e quando mi serve un numero | float(0) o | int(0), sempre con un valore di default.
  • Provare senza accendere niente. Per verificare il riconoscimento senza toccare i dispositivi rendo i template delle variabili uno dopo l'altro con l'API REST /api/template, passando a ognuno le variabili già calcolate con dei {% set %} in testa. Così vedo cosa capisce lo script per ogni frase della tabella.
  • I valori Tuya vanno confrontati con l'app. Nel mio caso tensione e potenza delle prese arrivavano moltiplicate per 10 (la correzione è in Smart Life in Home Assistant), e un termostato mostrava una scala di temperatura sospetta. Prima di fidarmi di un numero in una risposta, lo guardo accanto a quello dell'app.

Un esempio di prova con l'API, per la variabile numero: il corpo della richiesta contiene il template, con parole già impostata come la calcolerebbe lo script.

{
  "template": "{% set parole = ['luce', 'scrivania', '50'] %}{{ (parole | select('match', '^[0-9]+([.,][0-9]+)?$') | list + [''])[0] }}"
}

La risposta è 50. Rifatto per ogni variabile e per ogni frase, è il modo più rapido che ho trovato per sistemare il riconoscimento senza accendere e spegnere la casa a ogni tentativo.