+
+
+ Action Required in configuration.yaml
+
+
+ Could not find an include directive in configuration.yaml. Add this line to link the generated config:
+
+
+
+
+
+
The new Google Assistant YAML configuration has been successfully rebuilt. Would you like to restart Home Assistant now to apply the changes?
diff --git a/custom_components/google_assistant_entity_console/static/panel.js b/custom_components/google_assistant_entity_console/static/panel.js
index dec9270..37b472d 100644
--- a/custom_components/google_assistant_entity_console/static/panel.js
+++ b/custom_components/google_assistant_entity_console/static/panel.js
@@ -1,10 +1,36 @@
if (!customElements.get('google-assistant-entity-console-panel')) {
class GoogleAssistantEntityConsolePanel extends HTMLElement {
+ constructor() {
+ super();
+ this._lastToken = null;
+ this._iframe = null;
+ this._hass = null;
+ this._messageListener = (event) => {
+ if (event.data && event.data.type === 'REQUEST_HA_TOKEN') {
+ if (this._iframe && this._iframe.contentWindow && this._hass && this._hass.auth && this._hass.auth.accessToken) {
+ this._iframe.contentWindow.postMessage({
+ type: 'HA_TOKEN_UPDATE',
+ token: this._hass.auth.accessToken
+ }, '*');
+ }
+ }
+ };
+ }
+
+ connectedCallback() {
+ window.addEventListener('message', this._messageListener);
+ }
+
+ disconnectedCallback() {
+ window.removeEventListener('message', this._messageListener);
+ }
+
setConfig(config) {
this._config = config;
}
set hass(hass) {
+ this._hass = hass;
if (!this._iframe) {
this._iframe = document.createElement('iframe');
this._iframe.style.width = '100%';
@@ -15,9 +41,18 @@ if (!customElements.get('google-assistant-entity-console-panel')) {
}
// Update the src only once
if (!this._iframe.src) {
- const token = hass.auth.accessToken;
+ const token = hass.auth && hass.auth.accessToken ? hass.auth.accessToken : '';
+ this._lastToken = token;
const ver = (this._config && this._config.version) || Date.now();
this._iframe.src = `/google_assistant_entity_console/static/index.html?token=${encodeURIComponent(token)}&v=${ver}`;
+ } else if (hass.auth && hass.auth.accessToken && hass.auth.accessToken !== this._lastToken) {
+ this._lastToken = hass.auth.accessToken;
+ if (this._iframe.contentWindow) {
+ this._iframe.contentWindow.postMessage({
+ type: 'HA_TOKEN_UPDATE',
+ token: this._lastToken
+ }, '*');
+ }
}
}
}
diff --git a/custom_components/google_assistant_entity_console/views.py b/custom_components/google_assistant_entity_console/views.py
index c5b0d6f..87ed2d3 100644
--- a/custom_components/google_assistant_entity_console/views.py
+++ b/custom_components/google_assistant_entity_console/views.py
@@ -1,17 +1,21 @@
+import json
import logging
import os
import re
-from aiohttp import web
-import yaml
+from typing import Any
+import yaml
+from aiohttp import web
+from homeassistant.components.conversation import async_converse, async_get_agent_info
from homeassistant.components.http import HomeAssistantView
-from homeassistant.core import HomeAssistant
+from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import (
area_registry,
device_registry,
entity_registry,
floor_registry,
)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -68,34 +72,76 @@ def extract_yaml_header(content: str | None) -> list[str]:
return trimmed_lines
-import json
-def load_blocklist(hass: HomeAssistant) -> list:
- filepath = hass.config.path("google_assistant_entity_console_blocklist.json")
+def _sync_read_json(filepath: str, default: Any) -> Any:
if not os.path.exists(filepath):
- return []
+ return default
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
- if isinstance(data, list):
- return data
+ return data
except Exception as err:
- _LOGGER.error("Failed to load blocklist: %s", err)
- return []
+ _LOGGER.error("Failed to load JSON from %s: %s", filepath, err)
+ return default
-def save_blocklist(hass: HomeAssistant, blocklist: list):
- filepath = hass.config.path("google_assistant_entity_console_blocklist.json")
+
+def _sync_write_json(filepath: str, data: Any) -> None:
try:
with open(filepath, "w", encoding="utf-8") as f:
- json.dump(blocklist, f, indent=2)
+ json.dump(data, f, indent=2)
except Exception as err:
- _LOGGER.error("Failed to save blocklist: %s", err)
+ _LOGGER.error("Failed to save JSON to %s: %s", filepath, err)
+
+
+def _sync_write_file(path: str, content: str) -> None:
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+
+
+def _sync_update_config_include(config_path: str, filename: str) -> bool:
+ if not os.path.exists(config_path):
+ return False
+ with open(config_path, "r", encoding="utf-8") as f:
+ config_content = f.read()
+
+ include_pattern = r"google_assistant:\s*!include\s*(gaGen_\d{6}\.yaml)"
+ if re.search(include_pattern, config_content):
+ updated = re.sub(
+ include_pattern,
+ f"google_assistant: !include {filename}",
+ config_content,
+ )
+ with open(config_path, "w", encoding="utf-8") as f:
+ f.write(updated)
+ return True
+ return False
+
+
+def load_blocklist(hass: HomeAssistant) -> list:
+ filepath = hass.config.path("google_assistant_entity_console_blocklist.json")
+ data = _sync_read_json(filepath, default=[])
+ return data if isinstance(data, list) else []
+
+
+def save_blocklist(hass: HomeAssistant, blocklist: list) -> None:
+ filepath = hass.config.path("google_assistant_entity_console_blocklist.json")
+ _sync_write_json(filepath, blocklist)
+
+
+async def async_load_blocklist(hass: HomeAssistant) -> list:
+ return await hass.async_add_executor_job(load_blocklist, hass)
+
+
+async def async_save_blocklist(hass: HomeAssistant, blocklist: list) -> None:
+ await hass.async_add_executor_job(save_blocklist, hass, blocklist)
+
def _read_file_content(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
+
async def get_current_yaml_filename(hass: HomeAssistant):
config_path = hass.config.path("configuration.yaml")
try:
@@ -115,15 +161,15 @@ async def load_yaml_exposed_entities(hass: HomeAssistant, filename: str):
if not filename:
return {}
filepath = hass.config.path(filename)
-
+
try:
class CustomLoader(yaml.SafeLoader):
pass
CustomLoader.add_constructor('!secret', lambda loader, node: node.value)
CustomLoader.add_constructor('!include', lambda loader, node: node.value)
-
+
content = await hass.async_add_executor_job(_read_file_content, filepath)
-
+
data = yaml.load(content, Loader=CustomLoader)
if data and isinstance(data, dict):
entity_config = data.get("entity_config", {})
@@ -187,7 +233,7 @@ async def async_fetch_entities_data(hass: HomeAssistant):
device_name_map[device.id] = device.name_by_user or device.name
# Load blocklist
- blocklist = load_blocklist(hass)
+ blocklist = await async_load_blocklist(hass)
compiled_blocklist = []
for pattern in blocklist:
try:
@@ -241,7 +287,7 @@ async def async_fetch_entities_data(hass: HomeAssistant):
area_id = entry.area_id
if not area_id and device_id:
area_id = device_area_map.get(device_id)
-
+
area_name = "TBA"
floor_name = "TBA"
if area_id:
@@ -314,7 +360,7 @@ async def get(self, request: web.Request) -> web.Response:
try:
entities = await async_fetch_entities_data(hass)
yaml_filename = (await get_current_yaml_filename(hass)) or "None"
-
+
# Read version natively via Home Assistant loader
from homeassistant.loader import async_get_integration
try:
@@ -429,7 +475,7 @@ async def post(self, request: web.Request) -> web.Response:
continue
exposed_count += 1
-
+
# Check for Floor transition
ent_floor = entity["floor"] or "TBA"
if ent_floor != current_floor:
@@ -483,30 +529,21 @@ class CustomLoader(yaml.SafeLoader):
except Exception as val_err:
return self.json({"error": f"Generated YAML failed validation: {val_err}"}, status_code=500)
- # Write file
- with open(filepath, "w", encoding="utf-8") as f:
- f.write(cleaned_yaml)
+ # Write file via executor job
+ await hass.async_add_executor_job(_sync_write_file, filepath, cleaned_yaml)
- # Dynamically update configuration.yaml
+ # Dynamically update configuration.yaml via executor job
config_path = hass.config.path("configuration.yaml")
- if os.path.exists(config_path):
- with open(config_path, "r", encoding="utf-8") as f:
- config_content = f.read()
-
- include_pattern = r"google_assistant:\s*!include\s*gaGen_\d{6}\.yaml"
- if re.search(include_pattern, config_content):
- config_content = re.sub(
- include_pattern,
- f"google_assistant: !include {filename}",
- config_content
- )
- with open(config_path, "w", encoding="utf-8") as f:
- f.write(config_content)
- else:
- _LOGGER.warning("Could not find google_assistant !include line in configuration.yaml")
+ has_include = await hass.async_add_executor_job(
+ _sync_update_config_include, config_path, filename
+ )
+ if not has_include:
+ _LOGGER.warning("Could not find google_assistant !include line in configuration.yaml")
return self.json({
"success": True,
+ "has_include_directive": has_include,
+ "required_include": f"google_assistant: !include {filename}",
"exposed_count": exposed_count,
"yaml_written": filepath
})
@@ -536,7 +573,7 @@ class BlocklistView(HomeAssistantView):
async def get(self, request: web.Request) -> web.Response:
hass = request.app["hass"]
try:
- blocklist = load_blocklist(hass)
+ blocklist = await async_load_blocklist(hass)
return self.json({"blocklist": blocklist})
except Exception as err:
_LOGGER.exception("Failed to get blocklist")
@@ -551,7 +588,7 @@ async def post(self, request: web.Request) -> web.Response:
blocklist = body.get("blocklist")
if not isinstance(blocklist, list):
return self.json({"error": "Blocklist must be a list"}, status_code=400)
-
+
# Syntax validation for regexes
for pattern in blocklist:
if not isinstance(pattern, str):
@@ -561,7 +598,7 @@ async def post(self, request: web.Request) -> web.Response:
except (re.error, TypeError) as err:
return self.json({"error": f"Invalid regex pattern '{pattern}': {err}"}, status_code=400)
- save_blocklist(hass, blocklist)
+ await async_save_blocklist(hass, blocklist)
return self.json({"success": True, "blocklist": blocklist})
except Exception as err:
_LOGGER.exception("Failed to update blocklist")
@@ -587,10 +624,10 @@ async def post(self, request: web.Request) -> web.Response:
except (re.error, TypeError) as err:
return self.json({"error": f"Invalid regex pattern: {err}"}, status_code=400)
- blocklist = load_blocklist(hass)
+ blocklist = await async_load_blocklist(hass)
if pattern not in blocklist:
blocklist.append(pattern)
- save_blocklist(hass, blocklist)
+ await async_save_blocklist(hass, blocklist)
return self.json({"success": True, "blocklist": blocklist})
except Exception as err:
@@ -601,39 +638,32 @@ async def post(self, request: web.Request) -> web.Response:
# AI Settings Storage Helpers
def load_ai_settings(hass: HomeAssistant) -> dict:
filepath = hass.config.path("google_assistant_entity_console_ai_settings.json")
- if not os.path.exists(filepath):
- return {}
- try:
- with open(filepath, "r", encoding="utf-8") as f:
- data = json.load(f)
- if isinstance(data, dict):
- return data
- except Exception as err:
- _LOGGER.error("Failed to load AI settings: %s", err)
- return {}
+ data = _sync_read_json(filepath, default={})
+ return data if isinstance(data, dict) else {}
+
-def save_ai_settings(hass: HomeAssistant, settings: dict):
+def save_ai_settings(hass: HomeAssistant, settings: dict) -> None:
filepath = hass.config.path("google_assistant_entity_console_ai_settings.json")
- try:
- with open(filepath, "w", encoding="utf-8") as f:
- json.dump(settings, f, indent=2)
- except Exception as err:
- _LOGGER.error("Failed to save AI settings: %s", err)
+ _sync_write_json(filepath, settings)
+
+
+async def async_load_ai_settings(hass: HomeAssistant) -> dict:
+ return await hass.async_add_executor_job(load_ai_settings, hass)
+
+
+async def async_save_ai_settings(hass: HomeAssistant, settings: dict) -> None:
+ await hass.async_add_executor_job(save_ai_settings, hass, settings)
-# AI API Views
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.components.conversation import async_converse, async_get_agent_info
-from homeassistant.core import Context
async def async_call_llm(hass: HomeAssistant, prompt: str, settings: dict) -> str:
ai_source = settings.get("ai_source", "openai")
-
+
if ai_source == "home_assistant":
ha_agent_id = settings.get("ha_agent_id")
if not ha_agent_id:
raise ValueError("Home Assistant conversation agent is not selected. Please configure it in Settings.")
-
+
result = await async_converse(
hass=hass,
text=prompt,
@@ -641,29 +671,35 @@ async def async_call_llm(hass: HomeAssistant, prompt: str, settings: dict) -> st
context=Context(),
agent_id=ha_agent_id
)
-
+
# Check if error response
if result.response.response_type == "error":
- raise RuntimeError(f"HA Conversation Agent error: {result.response.as_dict().get('speech', {}).get('plain', {}).get('speech', 'Unknown error')}")
-
- response_text = result.response.as_dict().get("speech", {}).get("plain", {}).get("speech", "")
+ error_speech = result.response.as_dict().get("speech", {}).get("plain", {}).get("speech")
+ error_code = result.response.as_dict().get("data", {}).get("code")
+ raise RuntimeError(f"HA Conversation Agent error: {error_speech or error_code or 'Unknown error'}")
+
+ resp_dict = result.response.as_dict()
+ response_text = (
+ resp_dict.get("speech", {}).get("plain", {}).get("speech", "")
+ or resp_dict.get("card", {}).get("plain", {}).get("description", "")
+ )
if not response_text:
raise RuntimeError("HA Conversation Agent returned an empty response.")
return response_text.strip()
-
+
else: # openai compatible api
base_url = settings.get("base_url", "").rstrip("/")
api_key = settings.get("api_key", "")
model = settings.get("model", "")
-
+
if not base_url or not model:
raise ValueError("AI settings are incomplete. Please configure base URL and Model in Settings.")
-
+
session = async_get_clientsession(hass)
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
-
+
payload = {
"model": model,
"messages": [
@@ -672,7 +708,7 @@ async def async_call_llm(hass: HomeAssistant, prompt: str, settings: dict) -> st
"temperature": 0.1,
"max_tokens": 1000
}
-
+
async with session.post(f"{base_url}/chat/completions", headers=headers, json=payload, timeout=25) as resp:
if resp.status == 200:
data = await resp.json()
@@ -690,15 +726,61 @@ class AIHaAgentsView(HomeAssistantView):
async def get(self, request: web.Request) -> web.Response:
hass = request.app["hass"]
try:
- agents = async_get_agent_info(hass)
- serialized_agents = []
- for agent in agents:
- serialized_agents.append({
- "id": agent.id,
- "name": agent.name
- })
- return self.json({"agents": serialized_agents})
+ agents = []
+ seen_ids = set()
+
+ # 1. Query conversation entities (covers modern LLM integrations: OpenAI, Gemini, Claude, Ollama, etc.)
+ try:
+ for state in hass.states.async_all("conversation"):
+ entity_id = str(state.entity_id)
+ name_val = getattr(state, "name", None)
+ display_name = str(name_val) if isinstance(name_val, str) and name_val else entity_id
+ agents.append({
+ "id": entity_id,
+ "name": display_name,
+ })
+ seen_ids.add(entity_id)
+ except Exception as ex:
+ _LOGGER.debug("Could not query conversation entities: %s", ex)
+
+ # 2. Query AgentManager for registered agents (legacy or config entries)
+ try:
+ from homeassistant.components.conversation import get_agent_manager
+
+ manager = get_agent_manager(hass)
+ for agent_info in manager.async_get_agent_info():
+ aid = str(agent_info.id)
+ if aid not in seen_ids:
+ name_val = getattr(agent_info, "name", None)
+ display_name = str(name_val) if isinstance(name_val, str) and name_val else aid
+ agents.append({
+ "id": aid,
+ "name": display_name,
+ })
+ seen_ids.add(aid)
+ except Exception as ex:
+ _LOGGER.debug("Could not query agent manager: %s", ex)
+
+ # 3. Query async_get_agent_info (supports single AgentInfo or list for test mocks)
+ single_or_list = async_get_agent_info(hass)
+ if single_or_list:
+ candidates = single_or_list if isinstance(single_or_list, list) else [single_or_list]
+ for candidate in candidates:
+ raw_id = getattr(candidate, "id", None)
+ if raw_id is not None:
+ cand_id = str(raw_id)
+ if cand_id not in seen_ids:
+ name_val = getattr(candidate, "name", None)
+ display_name = str(name_val) if isinstance(name_val, str) and name_val else cand_id
+ agents.append({
+ "id": cand_id,
+ "name": display_name,
+ })
+ seen_ids.add(cand_id)
+
+ return self.json({"agents": agents})
except Exception as err:
+ _LOGGER.exception("Failed to query HA conversation agents")
return self.json({"error": str(err)}, status_code=500)
@@ -709,7 +791,7 @@ class AISettingsView(HomeAssistantView):
async def get(self, request: web.Request) -> web.Response:
hass = request.app["hass"]
try:
- settings = load_ai_settings(hass)
+ settings = await async_load_ai_settings(hass)
return self.json({"settings": settings})
except Exception as err:
return self.json({"error": str(err)}, status_code=500)
@@ -721,8 +803,8 @@ async def post(self, request: web.Request) -> web.Response:
settings = body.get("settings", {})
if not isinstance(settings, dict):
return self.json({"error": "Settings must be a dictionary"}, status_code=400)
-
- save_ai_settings(hass, settings)
+
+ await async_save_ai_settings(hass, settings)
return self.json({"success": True, "settings": settings})
except Exception as err:
return self.json({"error": str(err)}, status_code=500)
@@ -738,23 +820,23 @@ async def post(self, request: web.Request) -> web.Response:
body = await request.json()
base_url = body.get("base_url", "").rstrip("/")
api_key = body.get("api_key", "")
-
+
if not base_url:
return self.json({"error": "Missing base URL"}, status_code=400)
-
+
session = async_get_clientsession(hass)
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
-
+
async with session.get(f"{base_url}/models", headers=headers, timeout=10) as resp:
if resp.status != 200:
text = await resp.text()
return self.json({"error": f"Failed to fetch models (HTTP {resp.status}): {text}"}, status_code=resp.status)
-
+
data = await resp.json()
models = []
-
+
def parse_item(item):
if not isinstance(item, dict) or "id" not in item:
return None
@@ -774,7 +856,7 @@ def parse_item(item):
parsed = parse_item(item)
if parsed:
models.append(parsed)
-
+
# Sort models by name
models.sort(key=lambda m: m["name"].lower())
return self.json({"models": models})
@@ -794,13 +876,10 @@ async def post(self, request: web.Request) -> web.Response:
entities_to_gen = body.get("entities", [])
if not entities_to_gen:
return self.json({"error": "No entities provided"}, status_code=400)
-
- settings = load_ai_settings(hass)
- base_url = settings.get("base_url", "").rstrip("/")
- api_key = settings.get("api_key", "")
- model = settings.get("model", "")
+
+ settings = await async_load_ai_settings(hass)
prompt_template = settings.get("nickname_prompt", "").strip()
-
+
# Default fallback template
if not prompt_template:
prompt_template = (
@@ -812,12 +891,12 @@ async def post(self, request: web.Request) -> web.Response:
"Friendly Name: {friendly_name}\n"
"Aliases:"
)
-
+
results = {}
for ent in entities_to_gen:
ent_id = ent.get("entity_id")
friendly = ent.get("display_name") or ent.get("name") or ent_id.split(".")[-1]
-
+
# Render prompt
prompt = prompt_template.format(entity_id=ent_id, friendly_name=friendly)
try:
@@ -826,7 +905,7 @@ async def post(self, request: web.Request) -> web.Response:
results[ent_id] = aliases
except Exception as err:
_LOGGER.error("LLM nickname request failed for %s: %s", ent_id, err)
-
+
return self.json({"results": results})
except Exception as err:
_LOGGER.exception("Failed to generate nicknames")
@@ -843,18 +922,15 @@ async def post(self, request: web.Request) -> web.Response:
body = await request.json()
entity_list = body.get("entities", [])
user_intent = body.get("user_intent", "")
-
+
if not entity_list:
return self.json({"error": "No entities provided"}, status_code=400)
if not user_intent:
return self.json({"error": "No user intent/criteria provided"}, status_code=400)
-
- settings = load_ai_settings(hass)
- base_url = settings.get("base_url", "").rstrip("/")
- api_key = settings.get("api_key", "")
- model = settings.get("model", "")
+
+ settings = await async_load_ai_settings(hass)
prompt_template = settings.get("exposure_prompt", "").strip()
-
+
# Default fallback template
if not prompt_template:
prompt_template = (
@@ -865,7 +941,7 @@ async def post(self, request: web.Request) -> web.Response:
"Entities:\n"
"{entities_list}"
)
-
+
# Serialize entities briefly for the prompt context
serialized_ents = []
for ent in entity_list:
@@ -874,18 +950,18 @@ async def post(self, request: web.Request) -> web.Response:
"name": ent.get("display_name") or ent.get("name"),
"domain": ent.get("domain")
})
-
+
entities_str = json.dumps(serialized_ents, indent=2)
prompt = prompt_template.format(user_intent=user_intent, entities_list=entities_str)
-
+
content = await async_call_llm(hass, prompt, settings)
-
+
# Clean markdown formatting if returned
if content.startswith("```"):
content = re.sub(r"^```[a-zA-Z]*\n?", "", content)
content = re.sub(r"\n?```$", "", content)
content = content.strip()
-
+
try:
exposed_ids = json.loads(content)
if isinstance(exposed_ids, list):
@@ -909,16 +985,13 @@ async def post(self, request: web.Request) -> web.Response:
entity_id = body.get("entity_id")
display_name = body.get("display_name")
room_entities = body.get("room_entities", [])
-
+
if not entity_id:
return self.json({"error": "Missing entity_id"}, status_code=400)
-
- settings = load_ai_settings(hass)
- base_url = settings.get("base_url", "").rstrip("/")
- api_key = settings.get("api_key", "")
- model = settings.get("model", "")
+
+ settings = await async_load_ai_settings(hass)
prompt_template = settings.get("single_nickname_prompt", "").strip()
-
+
# Default fallback template
if not prompt_template:
prompt_template = (
@@ -932,7 +1005,7 @@ async def post(self, request: web.Request) -> web.Response:
"Other Entities in same room: {room_context}\n"
"Aliases:"
)
-
+
# Serialize room context with names and current nicknames/aliases
context_list = []
for e in room_entities:
@@ -941,13 +1014,13 @@ async def post(self, request: web.Request) -> web.Response:
aliases_str = f", current nicknames: {aliases_list}" if aliases_list else ""
context_list.append(f"{e.get('entity_id')} (name: {e.get('display_name') or e.get('name') or ''}{aliases_str})")
room_context_str = "; ".join(context_list) if context_list else "None"
-
+
prompt = prompt_template.format(
entity_id=entity_id,
friendly_name=display_name or entity_id.split(".")[-1],
room_context=room_context_str
)
-
+
content = await async_call_llm(hass, prompt, settings)
aliases = [a.strip().strip('"').strip("'") for a in content.split(",") if a.strip()]
return self.json({"aliases": aliases})
diff --git a/docs/superpowers/plans/2026-10-12-console-enhancements-async-io-token-refresh-and-branding.md b/docs/superpowers/plans/2026-10-12-console-enhancements-async-io-token-refresh-and-branding.md
new file mode 100644
index 0000000..6a75030
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-12-console-enhancements-async-io-token-refresh-and-branding.md
@@ -0,0 +1,273 @@
+# Console Enhancements, Async I/O, Token Refresh, and Branding Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Modernize Google Assistant Entity Console with non-blocking async file I/O, auto-refreshing authentication tokens, missing YAML include guidance, clean developer tooling (`pyproject.toml` and ruff), and a new Google-colored Home Assistant brand logo.
+
+**Architecture:** Refactor synchronous file I/O in `views.py` to use `hass.async_add_executor_job`, introduce bidirectional `postMessage` synchronization between `panel.js` and `app.js` for token resilience, detect missing `!include` directives in `configuration.yaml` during rebuilds with interactive UI guidance, set up standardized `pyproject.toml`, and generate SVG/PNG logo assets applying Google Assistant's 4-color palette to the Home Assistant network graph.
+
+**Tech Stack:** Python 3.12, Home Assistant custom components (`aiohttp`, `homeassistant.core`, `homeassistant.components.frontend`), PyYAML, pytest, pytest-asyncio, Ruff, Vanilla JavaScript (ES6+), SVG / Pillow (Python imaging).
+
+## Global Constraints
+
+- Preserve all existing file paths (`configuration.yaml`, `gaGen_*.yaml`, `google_assistant_entity_console_*.json`).
+- Zero blocking file I/O on the Home Assistant main event loop.
+- All 336 existing unit tests must continue to pass.
+- Code must pass `ruff check .` with zero errors.
+- Both repository files (`/drives/nfs/repos/google-assistant-entity-console`) and HA container files (`/ha/823SCD-Docker/custom_components/google_assistant_entity_console`) must remain in sync.
+
+---
+
+### Task 1: Developer Tooling (`pyproject.toml`) and Conftest Cleanups
+
+**Files:**
+- Create: `pyproject.toml`
+- Modify: `tests/conftest.py`
+- Test: `tests/test_yaml.py`
+
+**Interfaces:**
+- Produces: `pyproject.toml` defining pytest root and pythonpath `["."]`, ruff target version py312.
+
+- [ ] **Step 1: Create `pyproject.toml`**
+
+Write `pyproject.toml` in repository root:
+```toml
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
+asyncio_mode = "strict"
+
+[tool.ruff]
+line-length = 120
+target-version = "py312"
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "B", "SIM"]
+ignore = ["E501", "B008", "SIM108"]
+```
+
+- [ ] **Step 2: Fix `tests/conftest.py` mutable class attribute**
+
+Update line 137 in `tests/conftest.py`:
+Change `extra_urls = []` inside class definition to initialize `self.extra_urls = []` in `__init__` or annotate as `ClassVar`.
+
+- [ ] **Step 3: Run pytest directly without `PYTHONPATH=.`**
+
+Run: `pytest`
+Expected: 336 passed in ~1.5s.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add pyproject.toml tests/conftest.py
+git commit -m "chore: add pyproject.toml and fix conftest mutable class attribute"
+```
+
+---
+
+### Task 2: Non-Blocking Async File I/O in `views.py`
+
+**Files:**
+- Modify: `custom_components/google_assistant_entity_console/views.py`
+- Test: `tests/test_blocklist.py`, `tests/test_views.py`, `tests/test_yaml.py`
+
+**Interfaces:**
+- Consumes: `hass.async_add_executor_job`
+- Produces: Async helper functions `async_load_blocklist`, `async_save_blocklist`, `async_load_ai_settings`, `async_save_ai_settings`, non-blocking `RebuildView.post`.
+
+- [ ] **Step 1: Write tests for async file helpers**
+
+Add test cases in `tests/test_blocklist.py` verifying that blocklist read/write operations execute asynchronously without blocking the event loop and preserve JSON contents.
+
+- [ ] **Step 2: Implement file helper functions in `views.py`**
+
+Define:
+```python
+def _sync_read_json(filepath: str, default):
+ if not os.path.exists(filepath):
+ return default
+ try:
+ with open(filepath, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ return data
+ except Exception as err:
+ _LOGGER.error("Failed to load JSON from %s: %s", filepath, err)
+ return default
+
+def _sync_write_json(filepath: str, data) -> None:
+ try:
+ with open(filepath, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+ except Exception as err:
+ _LOGGER.error("Failed to save JSON to %s: %s", filepath, err)
+
+def _sync_write_file(filepath: str, content: str) -> None:
+ with open(filepath, "w", encoding="utf-8") as f:
+ f.write(content)
+```
+
+Update `load_blocklist` and `save_blocklist`, `load_ai_settings` and `save_ai_settings` to support both async and sync execution (or convert views to await executor job). In views (`BlocklistView`, `BlocklistAddView`, `AISettingsView`, `RebuildView`, `EntitiesView`), await the executor job so file I/O does not stall the loop.
+
+- [ ] **Step 3: Run pytest to verify all existing and new tests pass**
+
+Run: `pytest`
+Expected: PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add custom_components/google_assistant_entity_console/views.py tests/
+git commit -m "perf: offload file I/O to executor jobs in views.py"
+```
+
+---
+
+### Task 3: Missing YAML Include Detection in Backend and UI
+
+**Files:**
+- Modify: `custom_components/google_assistant_entity_console/views.py`
+- Modify: `custom_components/google_assistant_entity_console/static/app.js`
+- Modify: `custom_components/google_assistant_entity_console/static/index.html`
+- Test: `tests/test_yaml.py`
+
+**Interfaces:**
+- Produces: `RebuildView` JSON response fields: `has_include_directive: bool`, `required_include: str`.
+
+- [ ] **Step 1: Write test for missing include directive in `tests/test_yaml.py`**
+
+Test that when `configuration.yaml` does not have `google_assistant: !include ...`, `RebuildView` returns `has_include_directive: false` and the snippet string.
+
+- [ ] **Step 2: Update `RebuildView.post` in `views.py`**
+
+```python
+has_include = bool(re.search(include_pattern, config_content))
+if has_include:
+ # replace with filename
+...
+return self.json({
+ "success": True,
+ "has_include_directive": has_include,
+ "required_include": f"google_assistant: !include {filename}",
+ "exposed_count": exposed_count,
+ "yaml_written": filepath
+})
+```
+
+- [ ] **Step 3: Update `handleRebuild` in `app.js` and add missing include dialog in `index.html`**
+
+When `result.has_include_directive === false`, display a highlighted banner in the modal showing the required snippet with a "Copy Snippet" button.
+
+- [ ] **Step 4: Run pytest**
+
+Run: `pytest tests/test_yaml.py`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add custom_components/google_assistant_entity_console/ tests/test_yaml.py
+git commit -m "feat: add missing configuration.yaml include detection and UI snippet copy"
+```
+
+---
+
+### Task 4: Dynamic Auth Token Refresh in `panel.js` and `app.js`
+
+**Files:**
+- Modify: `custom_components/google_assistant_entity_console/static/panel.js`
+- Modify: `custom_components/google_assistant_entity_console/static/app.js`
+
+**Interfaces:**
+- `panel.js` posts `{ type: "HA_TOKEN_UPDATE", token: string }`
+- `app.js` listens for `HA_TOKEN_UPDATE` and posts `{ type: "REQUEST_HA_TOKEN" }` upon 401.
+
+- [ ] **Step 1: Update `panel.js`**
+
+Implement `this._hass = hass` and postMessage dispatch on token change:
+```javascript
+if (this._iframe && this._iframe.contentWindow && hass.auth && hass.auth.accessToken) {
+ this._iframe.contentWindow.postMessage({
+ type: "HA_TOKEN_UPDATE",
+ token: hass.auth.accessToken
+ }, "*");
+}
+```
+Add message event listener for `REQUEST_HA_TOKEN`.
+
+- [ ] **Step 2: Update `app.js`**
+
+Add message listener for `HA_TOKEN_UPDATE`. Wrap `fetch` calls in an authenticated fetch wrapper `apiFetch(url, options)` that detects 401 responses, requests a token update from parent window, and retries once.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add custom_components/google_assistant_entity_console/static/panel.js custom_components/google_assistant_entity_console/static/app.js
+git commit -m "feat: add automatic auth token synchronization and 401 retry handling"
+```
+
+---
+
+### Task 5: Brand & App Logo Redesign (Google Colors on Home Assistant Logo)
+
+**Files:**
+- Create: `brand/logo.svg`
+- Update: `brand/logo.png`
+- Update: `brand/icon.png`
+- Update: `custom_components/google_assistant_entity_console/static/logo.png`
+
+**Interfaces:**
+- SVG and PNGs depicting the Home Assistant house network graph with Google Assistant colors:
+ - Blue: `#4285F4`
+ - Red: `#EA4335`
+ - Yellow: `#FBBC05`
+ - Green: `#34A853`
+
+- [ ] **Step 1: Create master vector `brand/logo.svg`**
+
+Craft the Home Assistant geometric house network outline and internal nodes using Google Assistant colors with modern styling.
+
+- [ ] **Step 2: Generate PNG exports**
+
+Use Python with Pillow / Cairo / resvg or svg2png script to render crisp transparent 1024x1024 PNGs for `brand/logo.png`, `brand/icon.png`, and a 256x256 asset for `custom_components/google_assistant_entity_console/static/logo.png`.
+
+- [ ] **Step 3: Verify image outputs**
+
+Verify dimensions, transparency, and visual quality.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add brand/ custom_components/google_assistant_entity_console/static/logo.png
+git commit -m "style: redesign brand and app logo with Google Assistant colors on HA house graph"
+```
+
+---
+
+### Task 6: Ruff Cleanups, Full Verification, and Container Sync
+
+**Files:**
+- Modify: `custom_components/google_assistant_entity_console/views.py`
+- Modify: `tests/`
+- Target: `/ha/823SCD-Docker/custom_components/google_assistant_entity_console/`
+
+- [ ] **Step 1: Clean up remaining Ruff warnings**
+
+Remove unused variables in `views.py` (lines 853-855, 917-919). Format test imports and combine nested `with` statements.
+Run `ruff check .`
+Expected: 0 errors.
+
+- [ ] **Step 2: Run full test suite**
+
+Run `pytest`
+Expected: All tests pass.
+
+- [ ] **Step 3: Synchronize changes to Home Assistant Docker directory**
+
+Copy updated component files to `/ha/823SCD-Docker/custom_components/google_assistant_entity_console/`.
+
+- [ ] **Step 4: Commit and finalize**
+
+```bash
+git add -A
+git commit -m "chore: clean up linter warnings and finalize v1.7.1 release polish"
+```
diff --git a/docs/superpowers/specs/2026-10-12-console-enhancements-async-io-token-refresh-and-branding-design.md b/docs/superpowers/specs/2026-10-12-console-enhancements-async-io-token-refresh-and-branding-design.md
new file mode 100644
index 0000000..dbe09ee
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-12-console-enhancements-async-io-token-refresh-and-branding-design.md
@@ -0,0 +1,165 @@
+# Google Assistant Entity Console: Enhancements, Async I/O, Token Refresh, and Branding Design
+
+**Date:** 2026-10-12
+**Status:** Approved
+**Author:** spelech / Antigravity
+
+---
+
+## 1. Executive Summary
+
+This specification outlines key architectural, user experience, developer tooling, and branding enhancements for the **Google Assistant Entity Console** Home Assistant custom component:
+
+1. **Non-Blocking Async File I/O:** Eliminate event loop blocking (`ASYNC230`) across all backend endpoints by delegating synchronous file reads and writes to executor threads via `hass.async_add_executor_job`.
+2. **Dynamic Auth Token Refresh:** Establish bidirectional messaging between the parent Home Assistant web component (`panel.js`) and the console Single-Page Application iframe (`app.js`) to automatically synchronize renewed access tokens and prevent 401 session expirations.
+3. **YAML Include Directive Detection & Guidance:** Detect whether `configuration.yaml` includes the active `gaGen_*.yaml` configuration and present an actionable UI banner with a one-click copyable snippet if absent.
+4. **Developer Tooling & Linting Standards:** Introduce a standardized `pyproject.toml` file enabling zero-argument `pytest` execution (`pythonpath = ["."]`) and clean up all 46 `ruff` warnings.
+5. **Brand Logo Redesign:** Modernize the app and brand logo by adopting the iconic Home Assistant house network graph geometry infused with Google Assistant's signature four-color palette (`#4285F4`, `#EA4335`, `#FBBC05`, `#34A853`).
+
+---
+
+## 2. Architecture & Backend Enhancements (`views.py`)
+
+### 2.1 Async File I/O Optimization
+Currently, `load_blocklist`, `save_blocklist`, `load_ai_settings`, `save_ai_settings`, and `RebuildView.post` perform synchronous `with open(...)` operations on the main event loop thread.
+
+#### Changes:
+- Define discrete synchronous helper functions:
+ - `_sync_read_json(filepath: str, default: Any) -> Any`
+ - `_sync_write_json(filepath: str, data: Any) -> None`
+ - `_sync_write_text(filepath: str, content: str) -> None`
+ - `_sync_update_config_yaml(config_path: str, filename: str) -> tuple[bool, str]`
+- Convert `load_blocklist`, `save_blocklist`, `load_ai_settings`, and `save_ai_settings` into async functions or wrap their I/O invocations with `await hass.async_add_executor_job(...)`.
+- In `RebuildView.post`, perform the file write of `gaGen_YYMMDD.yaml` and the check/update of `configuration.yaml` in executor jobs.
+- Keep the exact file paths (`/config/google_assistant_entity_console_blocklist.json`, `/config/google_assistant_entity_console_ai_settings.json`, and `/config/configuration.yaml`) intact to ensure complete backward compatibility.
+
+### 2.2 Missing Include Directive Detection
+In `RebuildView.post`:
+- Read `configuration.yaml` via executor.
+- Look for `include_pattern = r"google_assistant:\s*!include\s*gaGen_\d{6}\.yaml"`.
+- If found:
+ - Replace with `f"google_assistant: !include {filename}"`.
+ - Write updated content.
+ - Return `{"success": True, "has_include_directive": True, "exposed_count": exposed_count, "yaml_written": filepath}`.
+- If NOT found:
+ - Return `{"success": True, "has_include_directive": False, "required_include": f"google_assistant: !include {filename}", "exposed_count": exposed_count, "yaml_written": filepath}`.
+
+### 2.3 Linter Fixes in `views.py`
+- Eliminate unused local variables in `views.py`:
+ - Lines 853-855: Remove unused `base_url`, `api_key`, `model` in `AISuggestExposureView`.
+ - Lines 917-919: Remove unused `base_url`, `api_key`, `model` in `AIGenerateSingleEntityNicknameView`.
+- Catch specific exceptions where feasible or document broad error bounds.
+
+---
+
+## 3. Frontend & Authentication Enhancements (`panel.js` & `app.js`)
+
+### 3.1 Token Refresh Architecture
+Home Assistant frontend rotates authentication tokens periodically. The panel wrapper (`panel.js`) receives updated `hass` properties, but the nested iframe (`index.html`) relies on the initial query parameter.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant HA as Home Assistant Core / Frontend
+ participant Panel as panel.js (Web Component)
+ participant Iframe as app.js (SPA inside Iframe)
+ participant API as /api/google_assistant_entity_console/*
+
+ HA->>Panel: set hass(hass) with refreshed accessToken
+ Panel->>Iframe: postMessage({ type: "HA_TOKEN_UPDATE", token })
+ Iframe->>Iframe: sessionStorage.setItem('ha_token', token)
+
+ Note over Iframe,API: On Token Expiry Fallback
+ Iframe->>API: HTTP Request (stale token)
+ API-->>Iframe: 401 Unauthorized
+ Iframe->>Panel: postMessage({ type: "REQUEST_HA_TOKEN" })
+ Panel-->>Iframe: postMessage({ type: "HA_TOKEN_UPDATE", token })
+ Iframe->>API: Retry HTTP Request with renewed token
+ API-->>Iframe: 200 OK
+```
+
+#### Implementation in `panel.js`:
+- Store `this._hass = hass;`
+- On `set hass(hass)`:
+ - If `this._iframe && this._iframe.contentWindow && hass.auth?.accessToken`:
+ - Dispatch `this._iframe.contentWindow.postMessage({ type: 'HA_TOKEN_UPDATE', token: hass.auth.accessToken }, '*')`.
+- Add window event listener for messages from the child iframe:
+ - When `{ type: 'REQUEST_HA_TOKEN' }` is received, respond with `{ type: 'HA_TOKEN_UPDATE', token: this._hass?.auth?.accessToken }`.
+
+#### Implementation in `app.js`:
+- Add message listener:
+ ```javascript
+ window.addEventListener('message', (event) => {
+ if (event.data && event.data.type === 'HA_TOKEN_UPDATE' && event.data.token) {
+ sessionStorage.setItem('ha_token', event.data.token);
+ }
+ });
+ ```
+- Wrap `fetch` calls with an authenticated client helper that intercepts 401 errors, requests a token renewal, and retries once before surfacing an error to the user.
+
+### 3.2 Missing YAML Include Notice in UI
+- In `handleRebuild()` in `app.js`:
+ - Inspect `result.has_include_directive`.
+ - If `false`, display a dedicated modal or highlight in the restart dialog with the snippet:
+ ```yaml
+ google_assistant: !include
+ ```
+ - Provide a "Copy to Clipboard" button and clear guidance that Home Assistant requires this directive in `configuration.yaml`.
+
+---
+
+## 4. Developer Experience & Testing (`pyproject.toml` & Test Suite)
+
+### 4.1 `pyproject.toml` Configuration
+Create `pyproject.toml` in repository root:
+```toml
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
+asyncio_mode = "strict"
+
+[tool.ruff]
+line-length = 100
+target-version = "py312"
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "ASYNC", "B", "SIM"]
+ignore = ["ASYNC230"] # for test files reading static test fixtures if necessary
+```
+
+### 4.2 Test Suite Cleanups
+- In `tests/conftest.py`: Convert mutable class attribute `extra_urls = []` to initialized instance property or `ClassVar`.
+- Clean import ordering across `tests/test_blocklist.py` and `tests/test_views.py`.
+- Consolidate nested `with` blocks into single compound `with` statements.
+- Ensure all 336 existing tests pass cleanly without `PYTHONPATH=.`.
+
+---
+
+## 5. Brand & App Logo Redesign
+
+### 5.1 Design Specifications
+- **Base Geometry:** Home Assistant's iconic interconnected house network graph.
+ - Roof, walls, and chimney framework formed by nodes and clean geometric edges.
+- **Color Palette (Google Assistant signature colors):**
+ - **Google Blue:** `#4285F4` (Roof peak and primary apex node)
+ - **Google Red:** `#EA4335` (Accent node / branch)
+ - **Google Yellow:** `#FBBC05` (Central living node / bridge)
+ - **Google Green:** `#34A853` (Foundation / ground node)
+ - **Neutral Framework:** `#5F6368` or clean semi-transparent connecting paths with rounded caps.
+- **Style:** Flat, modern, vector-based, high contrast on both dark and light surfaces.
+
+### 5.2 Asset Outputs
+- `brand/logo.svg`: Master vector source.
+- `brand/logo.png`: 1024x1024 master transparent PNG.
+- `brand/icon.png`: 1024x1024 master icon PNG.
+- `custom_components/google_assistant_entity_console/static/logo.png`: Optimized web asset for dashboard header.
+- Synchronized to `/ha/823SCD-Docker/custom_components/google_assistant_entity_console/static/logo.png`.
+
+---
+
+## 6. Verification & Validation Plan
+1. **Unit Tests:** Execute `pytest` directly to ensure 336/336 tests pass.
+2. **Linting:** Execute `ruff check .` to confirm 0 errors.
+3. **Async Verification:** Inspect views to ensure zero blocking `open()` calls in async contexts.
+4. **Token Refresh Verification:** Simulate token update postMessage events and verify `sessionStorage` updates.
+5. **Asset Inspection:** Verify rendered logo dimensions and transparency.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..86ac380
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,12 @@
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
+asyncio_mode = "strict"
+
+[tool.ruff]
+line-length = 120
+target-version = "py312"
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "B", "SIM"]
+ignore = ["E501", "B008", "SIM108"]
diff --git a/tests/conftest.py b/tests/conftest.py
index 2345186..e022756 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -134,7 +134,9 @@ class HomeAssistantView:
url = None
name = None
requires_auth = True
- extra_urls = []
+
+ def __init__(self):
+ self.extra_urls = []
def json(self, result, status_code: int = 200, headers: dict | None = None):
return web.json_response(result, status=status_code, headers=headers)
diff --git a/tests/test_blocklist.py b/tests/test_blocklist.py
index b4670a8..17e39e9 100644
--- a/tests/test_blocklist.py
+++ b/tests/test_blocklist.py
@@ -10,11 +10,12 @@
BlocklistAddView,
BlocklistView,
async_fetch_entities_data,
+ async_load_blocklist,
+ async_save_blocklist,
load_blocklist,
save_blocklist,
)
-
# ===========================================================================
# 1. Storage & Persistence: load_blocklist and save_blocklist
# ===========================================================================
@@ -44,6 +45,17 @@ def test_save_and_load_blocklist_roundtrip(mock_hass, tmp_path):
assert loaded == patterns
+@pytest.mark.anyio
+async def test_async_save_and_load_blocklist_roundtrip(mock_hass, tmp_path):
+ """Saves list of patterns asynchronously and reloads accurately."""
+ mock_hass.config.path.side_effect = lambda filename: str(tmp_path / filename)
+ patterns = [r"^switch\.secret_.*", r".*_power$"]
+
+ await async_save_blocklist(mock_hass, patterns)
+ loaded = await async_load_blocklist(mock_hass)
+ assert loaded == patterns
+
+
def test_load_blocklist_corrupted_json(mock_hass, tmp_path):
"""Handles corrupted JSON file gracefully by returning an empty list []."""
mock_hass.config.path.side_effect = lambda filename: str(tmp_path / filename)
diff --git a/tests/test_views.py b/tests/test_views.py
index 2b5928d..9fc4321 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -31,11 +31,12 @@
UpdateEntityView,
async_call_llm,
async_fetch_entities_data,
+ async_load_ai_settings,
+ async_save_ai_settings,
load_ai_settings,
save_ai_settings,
)
-
# ===========================================================================
# 1. Storage & Persistence Helpers: load_ai_settings and save_ai_settings
# ===========================================================================
@@ -70,6 +71,20 @@ def test_save_and_load_ai_settings_roundtrip(mock_hass, tmp_path):
assert loaded == sample_settings
+@pytest.mark.anyio
+async def test_async_save_and_load_ai_settings_roundtrip(mock_hass, tmp_path):
+ """Persists settings asynchronously and accurately reloads them."""
+ mock_hass.config.path.side_effect = lambda filename: str(tmp_path / filename)
+ sample_settings = {
+ "ai_source": "home_assistant",
+ "ha_agent_id": "conversation.home_assistant",
+ }
+
+ await async_save_ai_settings(mock_hass, sample_settings)
+ loaded = await async_load_ai_settings(mock_hass)
+ assert loaded == sample_settings
+
+
def test_load_ai_settings_corrupted_json(mock_hass, tmp_path):
"""Handles corrupted JSON file gracefully by returning empty dict {}."""
mock_hass.config.path.side_effect = lambda filename: str(tmp_path / filename)
@@ -450,6 +465,59 @@ async def test_ai_ha_agents_view_get_error(mock_hass):
assert data["error"] == "Registry error"
+@pytest.mark.anyio
+async def test_ai_ha_agents_view_discovers_conversation_entities(mock_hass):
+ """Discovers modern conversation entities registered in the state machine."""
+ view = AIHaAgentsView()
+ request = AsyncMock()
+ request.app = {"hass": mock_hass}
+
+ entity_gemini = MagicMock(entity_id="conversation.google_generative_ai")
+ entity_gemini.name = "Google Gemini"
+ entity_ollama = MagicMock(entity_id="conversation.ollama")
+ entity_ollama.name = "Ollama Llama 3"
+ mock_hass.states.async_all.return_value = [entity_gemini, entity_ollama]
+
+ with patch("custom_components.google_assistant_entity_console.views.async_get_agent_info", return_value=None):
+ resp = await view.get(request)
+ assert resp.status == 200
+ data = json.loads(resp.body.decode())
+ assert data["agents"] == [
+ {"id": "conversation.google_generative_ai", "name": "Google Gemini"},
+ {"id": "conversation.ollama", "name": "Ollama Llama 3"},
+ ]
+
+
+@pytest.mark.anyio
+async def test_ai_ha_agents_view_deduplicates_agents(mock_hass):
+ """Deduplicates agents if reported by both conversation entities and agent manager."""
+ view = AIHaAgentsView()
+ request = AsyncMock()
+ request.app = {"hass": mock_hass}
+
+ entity_openai = MagicMock(entity_id="conversation.openai")
+ entity_openai.name = "OpenAI ChatGPT"
+ mock_hass.states.async_all.return_value = [entity_openai]
+
+ mgr_agent = MagicMock(id="conversation.openai")
+ mgr_agent.name = "OpenAI ChatGPT"
+ mock_manager = MagicMock()
+ mock_manager.async_get_agent_info.return_value = [mgr_agent]
+
+ mock_conv_mod = MagicMock()
+ mock_conv_mod.get_agent_manager.return_value = mock_manager
+
+ with (
+ patch.dict("sys.modules", {"homeassistant.components.conversation": mock_conv_mod}),
+ patch("custom_components.google_assistant_entity_console.views.async_get_agent_info", return_value=[mgr_agent]),
+ ):
+ resp = await view.get(request)
+ assert resp.status == 200
+ data = json.loads(resp.body.decode())
+ assert len(data["agents"]) == 1
+ assert data["agents"][0]["id"] == "conversation.openai"
+
+
# ===========================================================================
# 5. UpdateEntityView: POST
# ===========================================================================
@@ -1115,9 +1183,11 @@ async def test_async_call_llm_ha_error_speech(mock_hass):
"speech": {"plain": {"speech": "Failed to contact upstream conversation model"}},
}
- with patch("custom_components.google_assistant_entity_console.views.async_converse", return_value=mock_result):
- with pytest.raises(RuntimeError, match="HA Conversation Agent error: Failed to contact upstream conversation model"):
- await async_call_llm(mock_hass, "Test prompt", test_settings)
+ with (
+ patch("custom_components.google_assistant_entity_console.views.async_converse", return_value=mock_result),
+ pytest.raises(RuntimeError, match="HA Conversation Agent error: Failed to contact upstream conversation model"),
+ ):
+ await async_call_llm(mock_hass, "Test prompt", test_settings)
@pytest.mark.anyio
@@ -1132,9 +1202,52 @@ async def test_async_call_llm_ha_error_speech_unknown(mock_hass):
mock_result.response.response_type = "error"
mock_result.response.as_dict.return_value = {}
+ with (
+ patch("custom_components.google_assistant_entity_console.views.async_converse", return_value=mock_result),
+ pytest.raises(RuntimeError, match="HA Conversation Agent error: Unknown error"),
+ ):
+ await async_call_llm(mock_hass, "Test prompt", test_settings)
+
+
+@pytest.mark.anyio
+async def test_async_call_llm_ha_error_code_fallback(mock_hass):
+ """Falls back to data.code when speech is absent in error response."""
+ test_settings = {
+ "ai_source": "home_assistant",
+ "ha_agent_id": "conversation.openai",
+ }
+
+ mock_result = MagicMock()
+ mock_result.response.response_type = "error"
+ mock_result.response.as_dict.return_value = {
+ "data": {"code": "intent_not_supported"}
+ }
+
+ with (
+ patch("custom_components.google_assistant_entity_console.views.async_converse", return_value=mock_result),
+ pytest.raises(RuntimeError, match="HA Conversation Agent error: intent_not_supported"),
+ ):
+ await async_call_llm(mock_hass, "Test prompt", test_settings)
+
+
+@pytest.mark.anyio
+async def test_async_call_llm_ha_card_fallback(mock_hass):
+ """Falls back to card description when speech is empty."""
+ test_settings = {
+ "ai_source": "home_assistant",
+ "ha_agent_id": "conversation.openai",
+ }
+
+ mock_result = MagicMock()
+ mock_result.response.response_type = "action_done"
+ mock_result.response.as_dict.return_value = {
+ "speech": {"plain": {"speech": ""}},
+ "card": {"plain": {"description": "alias1, alias2"}},
+ }
+
with patch("custom_components.google_assistant_entity_console.views.async_converse", return_value=mock_result):
- with pytest.raises(RuntimeError, match="HA Conversation Agent error: Unknown error"):
- await async_call_llm(mock_hass, "Test prompt", test_settings)
+ res = await async_call_llm(mock_hass, "Suggest nicknames", test_settings)
+ assert res == "alias1, alias2"
@pytest.mark.anyio
@@ -1151,9 +1264,11 @@ async def test_async_call_llm_ha_empty_speech(mock_hass):
"speech": {"plain": {"speech": ""}},
}
- with patch("custom_components.google_assistant_entity_console.views.async_converse", return_value=mock_result):
- with pytest.raises(RuntimeError, match="HA Conversation Agent returned an empty response"):
- await async_call_llm(mock_hass, "Test prompt", test_settings)
+ with (
+ patch("custom_components.google_assistant_entity_console.views.async_converse", return_value=mock_result),
+ pytest.raises(RuntimeError, match="HA Conversation Agent returned an empty response"),
+ ):
+ await async_call_llm(mock_hass, "Test prompt", test_settings)
@pytest.mark.anyio
@@ -1290,9 +1405,11 @@ async def test_async_call_llm_openai_http_error(mock_hass, err_status, err_body)
mock_session = MagicMock()
mock_session.post.return_value.__aenter__.return_value = mock_resp
- with patch("custom_components.google_assistant_entity_console.views.async_get_clientsession", return_value=mock_session):
- with pytest.raises(RuntimeError, match=f"API Error \\({err_status}\\): {err_body}"):
- await async_call_llm(mock_hass, "Prompt", test_settings)
+ with (
+ patch("custom_components.google_assistant_entity_console.views.async_get_clientsession", return_value=mock_session),
+ pytest.raises(RuntimeError, match=f"API Error \\({err_status}\\): {err_body}"),
+ ):
+ await async_call_llm(mock_hass, "Prompt", test_settings)
# ===========================================================================
diff --git a/tests/test_yaml.py b/tests/test_yaml.py
index f0b994f..941dc8e 100644
--- a/tests/test_yaml.py
+++ b/tests/test_yaml.py
@@ -398,6 +398,8 @@ async def test_yaml_header_rebuild_view_preserves_existing_header(mock_hass, tmp
data = json.loads(resp.body.decode())
assert data["success"] is True
assert data["exposed_count"] == 1
+ assert data["has_include_directive"] is True
+ assert "required_include" in data
written_path = data["yaml_written"]
with open(written_path, "r", encoding="utf-8") as f:
@@ -441,6 +443,8 @@ async def test_yaml_header_rebuild_view_defaults_when_no_existing_file(mock_hass
resp = await view.post(request)
data = json.loads(resp.body.decode())
assert data["success"] is True
+ assert data["has_include_directive"] is False
+ assert data["required_include"].startswith("google_assistant: !include gaGen_")
written_path = data["yaml_written"]
with open(written_path, "r", encoding="utf-8") as f: