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:
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
→
→
→
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:
accendioon,spegniooff; - 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:
- il testo intero: così
accendi fuoritrova la scena che si chiama proprio «Accendi fuori», invece di accendere qualcosa che si chiama «fuori»; - il testo senza le parole di comando e senza numeri:
luce scrivania 50diventaluce scrivania, e il 50 resta come valore; - il testo con i numeri: così
presa 2trova 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.
| Messaggio | Cosa capisce |
|---|---|
casa caldaia 21 | Caldaia a 21 °C (se è spenta, prima la accende) |
casa argo freddo | Condizionatore in modalità raffreddamento |
casa luce scrivania 50 | Luce della scrivania accesa al 50% |
casa spegni luci | Spegne tutte le luci |
casa presa 2 on | Accende la presa 2 (il 2 fa parte del nome) |
casa spegni tutte le prese | Spegne tutte le prese |
casa scena bagno | Attiva la scena «Bagno» |
casa accendi fuori | Attiva la scena «Accendi fuori» |
casa | Panoramica 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_temperatureeclimate.set_hvac_mode; - luci: light.turn_on con
brightness_pct, quando c'è un numero; - interruttori e prese:
homeassistant.turn_oneturn_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:
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
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.
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
variablesnel 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.