ha-mcp): L'agente AI legge lo stato dei carichi flessibili e invia comandi/suggerimenti per riprogrammare le attivazioni future.ha-mcp: L'agente non "parla" e basta; interroga e modifica lo stato di Home Assistant usando l'architettura standard Model Context Protocol.
+--------------------------------------------------------+
| 1. STRATO DI INTERFACCIA ED ESPOSIZIONE (User / Voice) |
+--------------------------------------------------------+
ā (Pipeline Vocale / Chat)
ā¼
+--------------------------------------------------------+
| 2. STRATO DI ORCHESTRAZIONE COGNITIVA (LangGraph AI) |
+--------------------------------------------------------+
ā (Richiesta Dati / Proposte di Modifica via MCP)
ā¼
+--------------------------------------------------------+
| 3. STRATO DI VALIDAZIONE E CONTROLLO (Il Middleware) |
+--------------------------------------------------------+
ā (Dati Validati / Comandi Sicuri)
ā¼
+--------------------------------------------------------+
| 4. STRATO DI OTTIMIZZAZIONE ALGORITMICA (EMHASS) |
+--------------------------------------------------------+
ā (Stato Sensori / Attuazioni Hardware)
ā¼
+--------------------------------------------------------+
| 5. STRATO DI INTEGRAZIONE FISICA (Home Assistant Core) |
+--------------------------------------------------------+
ha-mcp.State)messages: La cronologia della chat (testo utente e risposte AI).current_intent: L'obiettivo identificato (es. modifica_orario_carico, richiesta_status).proposed_plan_mod: La modifica temporanea al piano energetico proposta dall'AI.validation_result: L'esito del controllo del Middleware (Approvato / Rifiutato + Motivo).
[START] ā ( 1. NODO_PARSER )
ā
ā¼
( 2. NODO_MCP_GET )
ā
ā¼
( 3. NODO_CORE_REASONER ) āāāā ( Loop di conversazione se mancano dati )
ā
[Router Condizionale]
/ \
(Se propone modifiche) (Se chiede solo info)
/ \
ā¼ ā¼
( 4. NODO_VALIDATOR ) ( 6. NODO_ANSWER_GEN ) ā [END]
ā ā²
[Router di Esito] ā
/ \ ā
(Approvato) (Rifiutato) ā
/ \ ā
ā¼ ā¼ ā
(5. NODO_EMHASS) (Ri-ragionamento)
ā ā
āāāāāāāāāāāāāāāāāāāāāāāāāā
NODO_PARSER (Comprensione Intento)lavatrice), l'azione (spostare/ritardare) e la variabile temporale (più tardi).current_intent.NODO_MCP_GET (Recupero Contesto Energetico)ha-mcp per scattare una fotografia istantanea dell'impianto.NODO_CORE_REASONER (Il Cervello Decisionale)proposed_plan_mod.NODO_VALIDATOR (Il Controllo di Sicurezza)proposed_plan_mod) allo Strato 3 (il middleware/validator in Python).validation_result con Approved: True o Approved: False + Motivo.NODO_EMHASS_COMMIT (Consolidamento in EMHASS)Approved: True).NODO_ANSWER_GEN (Generazione Risposta Utente)[END]).NODO_CORE_REASONER.ha-mcp, puoi fare riferimento alle specifiche ufficiali di Anthropic e della community.
import os
from typing import Annotated, Dict, Any, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
# Nota: Si assume l'uso del backend di messaggistica standard di LangGraph
# e l'integrazione con un LLM compatibile con le chiamate a funzioni/tool (es. OpenAI o Anthropic)
# ==========================================
# 1. DEFINIZIONE DELLO STATO DEL GRAFO
# ==========================================
class EnergyManagerState(TypedDict):
# Elenco dei messaggi della chat (gestito automaticamente da add_messages)
messages: Annotated[list, add_messages]
# Intento rilevato (es. "modifica_piano", "richiesta_info")
current_intent: str
# Dati energetici correnti prelevati tramite ha-mcp
energy_context: Dict[str, Any]
# La modifica al piano energetico proposta dal LLM
proposed_plan_mod: Dict[str, Any]
# Risultato del controllo del Validator deterministico
validation_result: Dict[str, Any]
# ==========================================
# 2. IMPLEMENTAZIONE DEI NODI (FUNZIONI)
# ==========================================
def nodo_parser(state: EnergyManagerState) -> Dict[str, Any]:
"""Analizza l'input dell'utente per estrarre l'intento."""
last_message = state["messages"][-1].content.lower()
# Logica di parsing (semplificata per il PoC, implementabile con un mini-LLM o regex)
intent = "richiesta_info"
if any(keyword in last_message for keyword in ["sposta", "ritarda", "anticipa", "cambia", "carica"]):
intent = "modifica_piano"
return {"current_intent": intent}
def nodo_mcp_get(state: EnergyManagerState) -> Dict[str, Any]:
"""Invocazione fittizia dei tool ha-mcp per scattare lo snapshot dell'impianto."""
# Nella realtà qui chiameresti i tool esposti da ha-mcp verso Home Assistant
# Esempio: mcp_client.call_tool("get_emhass_plan", {})
mock_context = {
"production_forecast_ok": True,
"current_soc": 65, # % batteria
"flexible_loads": {
"lavatrice": {"scheduled_start": "11:00", "duration_hours": 2, "power_kw": 2.0},
"auto_elettrica": {"scheduled_start": "22:00", "duration_hours": 4, "power_kw": 3.7}
}
}
return {"energy_context": mock_context}
def nodo_core_reasoner(state: EnergyManagerState) -> Dict[str, Any]:
"""L'LLM valuta la richiesta alla luce dei dati MCP e propone una strategia."""
last_user_message = state["messages"][-1].content
context = state["energy_context"]
intent = state["current_intent"]
proposed_mod = {}
if intent == "modifica_piano":
# Qui il modello decide una proposta da sottoporre al validator.
# Esempio: l'utente ha chiesto di ritardare la lavatrice.
proposed_mod = {
"load_name": "lavatrice",
"action": "reschedule",
"new_start": "14:00"
}
return {"proposed_plan_mod": proposed_mod}
def nodo_validator(state: EnergyManagerState) -> Dict[str, Any]:
"""Strato 3: Il Middleware deterministico in Python puro (Zero AI).
Valida la fisica e i vincoli impianto.
"""
proposal = state["proposed_plan_mod"]
context = state["energy_context"]
# Logica di validazione fittizia:
# Se lo spostamento porta il carico in una fascia oraria con sole calante (es. dopo le 16:00)
# e il SoC è basso, rifiuta per proteggere la batteria.
if not proposal:
return {"validation_result": {"approved": True}}
new_hour = int(proposal["new_start"].split(":")[0])
if new_hour >= 16 and context["current_soc"] < 70:
return {
"validation_result": {
"approved": False,
"reason": "La batteria è al 65% e dopo le 16:00 la produzione solare cala. "
"Rischieresti di scaricare l'accumulo prima di sera."
}
}
return {"validation_result": {"approved": True, "reason": "Ottimizzazione sicura."}}
def nodo_emhass_commit(state: EnergyManagerState) -> Dict[str, Any]:
"""Applica fisicamente la modifica inviandola a EMHASS tramite servizio HA (via MCP)."""
proposal = state["proposed_plan_mod"]
# Qui invocheresti il tool MCP di scrittura:
# mcp_client.call_tool("set_emhass_load_window", {"load": proposal["load_name"], "time": proposal["new_start"]})
print(f"[EMHASS COMMIT] Schedulazione aggiornata per {proposal['load_name']} alle ore {proposal['new_start']}")
return {}
def nodo_answer_gen(state: EnergyManagerState) -> Dict[str, Any]:
"""Genera la risposta finale (testo per la pipeline vocale TTS di HA)."""
intent = state["current_intent"]
validation = state.get("validation_result", {"approved": True})
proposal = state.get("proposed_plan_mod", {})
if intent == "richiesta_info":
response = "Oggi l'obiettivo di autoconsumo è confermato al 92%. C'è un surplus di energia previsto tra le 13:00 e le 15:00."
else:
if validation["approved"]:
response = f"Ottima idea! Ho ricalcolato il piano energetico spostando la {proposal['load_name']} alle {proposal['new_start']}. L'autoconsumo resta ottimizzato."
else:
response = f"Non posso spostare la {proposal['load_name']} a quell'ora. {validation['reason']}"
# Creazione del messaggio di risposta dell'AI (Formato LangGraph standard)
from langchain_core.messages import AIMessage
return {"messages": [AIMessage(content=response)]}
# ==========================================
# 3. LOGICA DI ROUTING CONDIZIONALE
# ==========================================
def route_dopo_reasoner(state: EnergyManagerState) -> Literal["nodo_validator", "nodo_answer_gen"]:
"""Decide se andare al controllo di sicurezza o rispondere direttamente."""
if state["current_intent"] == "modifica_piano":
return "nodo_validator"
return "nodo_answer_gen"
def route_dopo_validator(state: EnergyManagerState) -> Literal["nodo_emhass_commit", "nodo_answer_gen"]:
"""Se approvato scrive su EMHASS, altrimenti va direttamente alla risposta di rifiuto."""
if state["validation_result"]["approved"]:
return "nodo_emhass_commit"
return "nodo_answer_gen"
# ==========================================
# 4. COSTRUZIONE E COMPILAZIONE DEL GRAFO
# ==========================================
workflow = StateGraph(EnergyManagerState)
# Aggiunta dei Nodi
workflow.add_node("nodo_parser", nodo_parser)
workflow.add_node("nodo_mcp_get", nodo_mcp_get)
workflow.add_node("nodo_core_reasoner", nodo_core_reasoner)
workflow.add_node("nodo_validator", nodo_validator)
workflow.add_node("nodo_emhass_commit", nodo_emhass_commit)
workflow.add_node("nodo_answer_gen", nodo_answer_gen)
# Definizione dei Collegamenti (Edges)
workflow.add_edge(START, "nodo_parser")
workflow.add_edge("nodo_parser", "nodo_mcp_get")
workflow.add_edge("nodo_mcp_get", "nodo_core_reasoner")
# Router dopo il ragionamento dell'LLM
workflow.add_conditional_edges(
"nodo_core_reasoner",
route_dopo_reasoner
)
# Router dopo la validazione del Middleware
workflow.add_conditional_edges(
"nodo_validator",
route_dopo_validator
)
# Chiusura dei flussi verso la generazione risposta
workflow.add_edge("nodo_emhass_commit", "nodo_answer_gen")
workflow.add_edge("nodo_answer_gen", END)
# Compilazione dell'applicazione finale
app = workflow.compile()
# ==========================================
# 5. ESEMPIO DI ESECUZIONE (TEST)
# ==========================================
if __name__ == "__main__":
from langchain_core.messages import HumanMessage
# Simuliamo un input utente critico (rifiutato dal validator)
inputs = {"messages": [HumanMessage(content="Sposta la lavatrice stasera tardi verso le 18")]}
config = {"configurable": {"thread_id": "casa_utente_1"}}
print("--- Avvio esecuzione Grafo ---")
for output in app.stream(inputs, config):
for key, value in output.items():
print(f"\n[Nodo Eseguito]: {key}")
if "messages" in value:
print(f"Risposta parziale: {value['messages'][-1].content}")
app.astream) in una funzione asincrona nativa di HA (async_def).nodo_mcp_get e nodo_emhass_commit utilizzeranno un client asincrono per connettersi al server MCP locale (ha-mcp), mappando le chiamate direttamente sui servizi emhass.reg_forecast_publisher o sugli input helper di Home Assistant.SystemMessage) specifico per configurare l'LLM all'interno del NODO_CORE_REASONER, in modo da istruirlo su come interpretare i dati energetici di EMHASS. Come preferisci procedere?ha-mcp) espone le entità e i servizi di Home Assistant sotto forma di Tool descritti in formato JSON-Schema.get_energy_status: Legge lo stato istantaneo (SoC batteria, produzione FV, consumi).get_emhass_plan: Recupera il piano orario corrente calcolato da EMHASS per i carichi flessibili.update_emhass_schedule: Modifica la finestra temporale desiderata di un carico flessibile (es. lavatrice) inviando il dato al Validator.ha-mcp (eseguito solitamente come add-on o container separato), estrarre i tool e usarli nativamente dentro i nodi LangGraph utilizzando la libreria ufficiale mcp e langchain_openai (o langchain_anthropic).
import asyncio
from typing import List, Dict, Any
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import ToolNode
# =====================================================================
# 1. CONFIGURAZIONE CONNESSIONE AL SERVER MCP (ha-mcp)
# =====================================================================
# Definiamo come l'agente spawna o si connette al server ha-mcp.
# Se ha-mcp è un container separato, si userà un trasporto di tipo SSE (HTTP).
# Se viene eseguito in locale via CLI, si usa Stdio. Qui simuliamo lo Stdio.
server_params = StdioServerParameters(
command="npx",
args=["-y", "@mcp-server/homeassistant"] # Sostituire con il pacchetto specifico ha-mcp se custom
)
# =====================================================================
# 2. DEFINIZIONE DEI TOOL ATTRAVERSO IL PROTOCOLLO MCP
# =====================================================================
# Creiamo dei wrapper LangChain attorno alle chiamate di sessione MCP.
# Nota: In un'implementazione reale, puoi automatizzare questo mapping
# ciclando su session.list_tools() fornito dal server MCP.
class MCPToolWrapper:
def __init__(self, session: ClientSession):
self.session = session
def get_tools(self) -> List[Any]:
@tool
async def get_energy_status() -> str:
"""Recupera lo stato energetico in tempo reale della casa: SoC batteria, produzione FV attuale e consumi."""
# Effettua la chiamata standardizzata MCP verso il server di Home Assistant
result = await self.session.call_tool("get_states", arguments={"entity_ids": ["sensor.battery_soc", "sensor.power_production", "sensor.power_consumption"]})
return str(result.content)
@tool
async def get_emhass_plan() -> str:
"""Recupera il piano di ottimizzazione orario corrente generato da EMHASS per i carichi differibili."""
result = await self.session.call_tool("get_emhass_production_plan", arguments={})
return str(result.content)
@tool
async def update_emhass_schedule(load_name: str, requested_start_time: str) -> str:
"""Invia una richiesta di riprogrammazione oraria per un carico differibile.
Passa prima dal Validator prima di essere consolidata su EMHASS.
Args:
load_name: Il nome del carico (es. 'lavatrice', 'lavastoviglie').
requested_start_time: L'orario richiesto in formato HH:MM (es. '14:30').
"""
# Chiama il servizio di Home Assistant esposto tramite MCP che punta al nostro Middleware/Validator
result = await self.session.call_tool(
"call_service",
arguments={
"domain": "energy_manager",
"service": "validate_and_schedule",
"service_data": {
"load": load_name,
"time": requested_start_time
}
}
)
return str(result.content)
return [get_energy_status, get_emhass_plan, update_emhass_schedule]
# =====================================================================
# 3. INTEGRAZIONE DEI TOOL MCP NEL RAGIONAMENTO DEL GRAFO
# =====================================================================
async def main():
# Inizializziamo il client MCP
async with stdio_client(server_params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
# Inizializza la sessione protocollo
await session.initialize()
# Estraiamo i tool mappati su MCP
mcp_tools = MCPToolWrapper(session).get_tools()
# Creiamo il ToolNode nativo di LangGraph per gestire le esecuzioni dei tool
tool_node = ToolNode(mcp_tools)
# Configuriamo il modello LLM agganciandogli i tool MCP
model = ChatOpenAI(model="gpt-4o", temperature=0).bind_tools(mcp_tools)
# --- ESEMPIO DI COINVOLGIMENTO DEI TOOL IN UN NODO ---
# Questo frammento simula ciò che accade dentro il NODO_CORE_REASONER
system_prompt = SystemMessage(
content="Sei l'Energy Manager vocale della casa. Hai accesso ai tool MCP per controllare "
"lo stato dell'impianto, i piani EMHASS e richiedere spostamenti di carico. "
"Rispondi in modo conciso e focalizzato sull'autoconsumo."
)
user_message = HumanMessage(content="Posso spostare la lavatrice alle 14:00 oggi?")
print(f"User: {user_message.content}\n")
print("[LangGraph Core Reasoner] L'agente valuta la richiesta e decide quali tool MCP invocare...")
# Primo step di ragionamento dell'LLM
response = await model.ainvoke([system_prompt, user_message])
# Se l'LLM decide che ha bisogno di dati, genererà delle 'tool_calls'
if response.tool_calls:
for tool_call in response.tool_calls:
print(f"ā L'AI decide di chiamare il Tool MCP: '{tool_call['name']}' con argomenti: {tool_call['args']}")
# Il grafo passerebbe questi comandi al ToolNode per l'esecuzione reale su Home Assistant
# Es: tool_output = await tool_node.ainvoke({"messages": [response]})
else:
print(f"AI: {response.content}")
# Esecuzione dell'ambiente asincrono (necessario per MCP)
if __name__ == "__main__":
asyncio.run(main())
ha-mcp (session.list_tools()). Se aggiungi un nuovo elettrodomestico differibile su Home Assistant, questo viene esposto all'AI automaticamente senza riscrivere il codice Python del grafo.NODO_CORE_REASONER, se l'utente fa una domanda complessa (es. "Manteniamo l'obiettivo se ritardo?"), l'LLM genera una chiamata a get_emhass_plan e get_energy_status. LangGraph interrompe temporaneamente il flusso stringente del testo, esegue i tool tramite il protocollo MCP, inietta i risultati JSON nel contesto e ripassa la palla all'LLM per formulare la risposta energetica o la proposta per il Validator.NODO_CORE_REASONER
Sei l'Agente AI di un Energy Manager residenziale avanzato. Il tuo obiettivo è assistere l'utente via voce o chat per massimizzare l'autoconsumo dell'impianto (Fotovoltaico, Batteria, Pompa di Calore e 2 Carichi Differibili).
Operi come un'interfaccia conversazionale intelligente posizionata sopra EMHASS (motore di ottimizzazione matematica) e un Validator deterministico di sicurezza.
REGOLE RIGIDE DI RAGIONAMENTO ENERGETICO:
1. UNIDÀ DI MISURA: Non confondere MAI la potenza istantanea (kW) con l'energia accumulata o consumata nel tempo (kWh).
2. PRIORITÀ DI CONSUMO: La priorità assoluta è coprire i carichi domestici con il fotovoltaico. La seconda è caricare la batteria. La terza è alimentare i carichi differibili (es. lavatrice, auto) se c'è potenza in eccesso ("surplus").
3. ACCUMULO (SoC): Se il SoC (Stato di Carica) della batteria è basso (sotto il 30%) o cala rapidamente, sii estremamente conservativo. Non suggerire l'attivazione di carichi pesanti a meno che la produzione FV attuale non superi la somma di tutti i consumi attivi.
4. METEO E PREVISIONI: Se l'utente chiede modifiche per il giorno successivo, controlla i dati di produzione stimati da EMHASS. Se è previsto maltempo, suggerisci di mantenere alto l'obiettivo di SoC di fine giornata per sicurezza.
REGOLE DI COMPORTAMENTO E PROTOCOLLO:
- TU NON ATTUI NULLA DIRETTAMENTE: Non hai il permesso di accendere o spegnere entità. Puoi solo "proporre" modifiche di schedulazione.
- IL VALIDATOR HA L'ULTIMA PAROLA: Quando l'utente ti chiede di spostare o attivare un carico (es. "Sposta la lavatrice alle 14"), tu devi formulare una proposta strutturata e invocare il tool `update_emhass_schedule`.
- GESTIONE RIFIUTI: Se il Validator restituisce un esito negativo (approved=False), non contraddire il sistema. Spiega all'utente il motivo tecnico e fisico (es. sovraccarico, mancanza di sole, salvaguardia batteria) in modo semplice e cordiale, proponendo un'alternativa (es. il giorno dopo o due ore prima).
STILE DI COMUNICAZIONE:
- Sii sintetico, chiaro e diretto. L'utente ti ascolta via voce: evita risposte lunghe, liste chilometriche o dettagli tecnici superflui a meno che non vengano richiesti espressamente.
- Usa espressioni naturali: "Ho verificato il piano di oggi...", "C'è un surplus di energia verso le...", "Ti consiglio di...".
ConversationEntity di Home Assistant.
config/
āāā custom_components/
āāā energy_assistant/
āāā __init__.py # Inizializzazione della piattaforma e client MCP
āāā manifest.json # Configurazione e dipendenze (langgraph, mcp)
āāā conversation.py # Il core che intercetta la voce/chat
manifest.json
{
"domain": "energy_assistant",
"name": "Energy Manager Conversazionale AI",
"codeowner": ["@tuo_username"],
"documentation": "https://github.com",
"dependencies": ["conversation"],
"requirements": [
"langgraph>=0.1.0",
"mcp>=0.1.0",
"langchain-openai>=0.1.0"
],
"version": "1.0.0",
"iot_class": "cloud_polling"
}
conversation.pyasync_process.
import logging
from typing import Literal
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.config_entries import ConfigEntry
from homeassistant.components.conversation import (
ConversationEntity,
ConversationResult,
ResultType,
)
from homeassistant.helpers.intent import IntentResponse
# Importi il tuo grafo compilato (dal file in cui hai scritto il codice LangGraph)
from .agent_graph import app as langgraph_agent_app
from langchain_core.messages import HumanMessage
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Configura la piattaforma di conversazione partendo dal custom component."""
async_add_entities([EnergyAssistantAgent(hass, config_entry)])
class EnergyAssistantAgent(ConversationEntity):
"""Rappresentazione dell'agente AI come entità di conversazione di Home Assistant."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self._entry = entry
self._attr_name = "Energy AI Manager"
# L'ID univoco permette di selezionare questo agente nelle impostazioni di Assist
self._attr_unique_id = f"{entry.entry_id}_conversation_agent"
@property
def supported_languages(self) -> list[str] | Literal["*"]:
"""Dichiara le lingue supportate dall'agente."""
return ["it", "en"]
async def async_process(self, user_input) -> ConversationResult:
"""Metodo Core: Intercetta il testo della chat/voce e lo passa a LangGraph."""
text_input = user_input.text
conversation_id = user_input.conversation_id or "default_home"
_LOGGER.info("Energy Assistant ha ricevuto l'input vocale: %s", text_input)
# 1. Preparazione dell'input per LangGraph nel formato atteso
inputs = {"messages": [HumanMessage(content=text_input)]}
# Usiamo l'ID conversazione di HA come thread_id per mantenere la memoria di LangGraph
config = {"configurable": {"thread_id": conversation_id}}
try:
# 2. Esecuzione asincrona del Grafo LangGraph
# Nota: il grafo internamente userà ha-mcp (connesso tramite i servizi interni di HA)
result_state = await langgraph_agent_app.ainvoke(inputs, config)
# 3. Estrazione dell'ultimo messaggio di risposta generato dall'AI
ai_messages = result_state.get("messages", [])
if ai_messages:
final_text_response = ai_messages[-1].content
else:
final_text_response = "Scusami, ho riscontrato un errore nel calcolo del piano energetico."
except Exception as err:
_LOGGER.error("Errore durante l'elaborazione del grafo LangGraph: %s", err)
final_text_response = "In questo momento non riesco a comunicare con il motore EMHASS."
# 4. Impacchettamento della risposta da rimandare alla pipeline STT/TTS di HA
intent_response = IntentResponse(language=user_input.language)
intent_response.async_set_speech(final_text_response)
return ConversationResult(
response=intent_response,
unmatched_identifier=None,
response_type=ResultType.ACTION_DONE
)
custom_components e riavviato Home Assistant: