From 7bb50a7e1f83d0fba47e31f8728088211da9ff65 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sat, 17 Oct 2026 15:18:05 -0500 Subject: [PATCH 1/8] docs: add spec and implementation plan for v3.0.0 .storage-native zero-reboot architecture --- .../2026-10-17-storage-native-zero-reboot.md | 1152 +++++++++++++++++ ...10-17-storage-native-zero-reboot-design.md | 233 ++++ 2 files changed, 1385 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-17-storage-native-zero-reboot.md create mode 100644 docs/superpowers/specs/2026-10-17-storage-native-zero-reboot-design.md diff --git a/docs/superpowers/plans/2026-10-17-storage-native-zero-reboot.md b/docs/superpowers/plans/2026-10-17-storage-native-zero-reboot.md new file mode 100644 index 0000000..318c657 --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-storage-native-zero-reboot.md @@ -0,0 +1,1152 @@ +# .storage-Native & Zero-Reboot Google Assistant 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:** Eliminate `gaGen_*.yaml` generation, `configuration.yaml` requirements, and Home Assistant restarts by managing Google Assistant credentials and entity exposure 100% in Home Assistant's `.storage` and syncing live in memory via `GoogleConfig` and Google HomeGraph's `devices:requestSync` API. + +**Architecture:** We introduce `ConsoleStore` (`store.py`) backed by `homeassistant.helpers.storage.Store` (`.storage/google_assistant_entity_console`) to persist Google credentials, last-synced entity metadata, blocklist patterns, and AI settings (with automatic first-run import from legacy YAML/JSON files). `GoogleAssistantRuntimeManager` (`runtime.py`) either attaches to an existing YAML-bootstrapped `GoogleConfig` instance or initializes HA Core's `GoogleConfig` + `GoogleAssistantView` directly from `.storage`, mutating `google_config._config["entity_config"]` in memory on every edit and invoking `await google_config.async_sync_entities_all()` on demand or via 15s debounced auto-sync. + +**Tech Stack:** Python 3.12+, Home Assistant Core (`homeassistant.helpers.storage.Store`, `homeassistant.components.google_assistant.http.GoogleConfig`, `GoogleAssistantView`, `entity_registry`, `area_registry`), Vanilla ES6 JavaScript (`static/app.js`), HTML5/CSS3 (`static/index.html`, `static/style.css`), `pytest`, `pytest-asyncio`, `playwright`. + +## Global Constraints + +- **Zero Reboots:** Entity exposure, name, alias, room, and credential updates MUST take effect in memory immediately without calling `homeassistant.restart`. +- **100% `.storage` Persistence:** All integration data (`google_settings`, `synced_entities`, `blocklist`, `ai_settings`) MUST be stored in `.storage/google_assistant_entity_console` via `homeassistant.helpers.storage.Store(hass, 1, "google_assistant_entity_console")`. +- **Backwards Compatibility & Auto-Migration:** Existing `/config/google_assistant_entity_console_blocklist.json`, `/config/google_assistant_entity_console_ai_settings.json`, `/config/configuration.yaml`, `/config/secrets.yaml`, `/config/gaGen_*.yaml`, and `/config/SERVICE_ACCOUNT.json` MUST be auto-imported into `.storage` on first load if `.storage` is unpopulated. +- **Verification Rule:** All backend unit tests (`pytest`) and Playwright E2E tests (`tests/test_playwright_e2e.py`), plus verification against a running Home Assistant instance, MUST pass before merging to `main`. + +--- + +## File Structure + +- **Create:** `custom_components/google_assistant_entity_console/store.py` + - `ConsoleStore`: Manages `.storage/google_assistant_entity_console` persistence, YAML/`!secret`/`!include` resolution, legacy JSON migration, and `comment_out_google_assistant_yaml()` helper. +- **Create:** `custom_components/google_assistant_entity_console/runtime.py` + - `GoogleAssistantRuntimeManager`: Manages HA Core's `GoogleConfig` instance in `"yaml_attached"` or `"storage"` mode, builds live `entity_config` dicts from `core.entity_registry`, mutates `google_config._config` in memory, and triggers `async_sync_entities_all()`. +- **Modify:** `custom_components/google_assistant_entity_console/const.py` + - Add storage constants (`STORAGE_KEY`, `STORAGE_VERSION`, `DATA_STORE`, `DATA_RUNTIME`). +- **Modify:** `custom_components/google_assistant_entity_console/__init__.py` + - Initialize `ConsoleStore` and `GoogleAssistantRuntimeManager` in `async_setup_entry`, and register the new HTTP views (`SyncView`, `GoogleSettingsView`, `MigrateYamlView`). +- **Modify:** `custom_components/google_assistant_entity_console/views.py` + - Refactor `async_fetch_entities_data`, `EntitiesView`, `UpdateEntityView`, `BlocklistView`, `BlocklistAddView`, and `AISettingsView` to use `ConsoleStore` and `GoogleAssistantRuntimeManager`. + - Add `SyncView` (`/api/google_assistant_entity_console/sync`), `GoogleSettingsView` (`/api/google_assistant_entity_console/google_settings`), and `MigrateYamlView` (`/api/google_assistant_entity_console/migrate_yaml`). Keep `RebuildView` as a backwards-compatible alias to `SyncView`. +- **Modify:** `custom_components/google_assistant_entity_console/static/index.html`, `static/app.js`, `static/style.css` + - Update header button from **Rebuild YAML** to **Sync to Google** (calling `/sync` without triggering `restartModal`). + - Add **Google Assistant Connection (`.storage`)** configuration section and **Migrate from YAML** button inside the Settings modal. +- **Create:** `tests/test_store.py` + - Unit tests for `ConsoleStore`, legacy JSON migration, YAML/`!secret`/`!include` auto-import, and `configuration.yaml` commenting out. +- **Create:** `tests/test_runtime.py` + - Unit tests for `GoogleAssistantRuntimeManager` in both `"yaml_attached"` and `"storage"` modes, live `_config["entity_config"]` updates, and `async_sync_to_google()`. +- **Modify:** `tests/test_views.py`, `tests/test_blocklist.py`, `tests/test_entities.py`, `tests/test_playwright_e2e.py` + - Update existing tests for `.storage`-backed views and add E2E tests for live sync and Google Connection settings. + +--- + +### Task 1: Native `.storage` Store & Legacy YAML/JSON Auto-Migration (`store.py`) + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/const.py` +- Create: `custom_components/google_assistant_entity_console/store.py` +- Create: `tests/test_store.py` + +**Interfaces:** +- Consumes: `homeassistant.core.HomeAssistant`, `homeassistant.helpers.storage.Store` +- Produces: + - `const.py`: `DOMAIN`, `STORAGE_KEY = "google_assistant_entity_console"`, `STORAGE_VERSION = 1`, `DATA_STORE = "store"`, `DATA_RUNTIME = "runtime"` + - `store.py`: + - `class ConsoleStore`: + - `async def async_load(self) -> dict[str, Any]` + - `async def async_save(self) -> None` + - `property google_settings -> dict[str, Any]` + - `async def async_update_google_settings(self, updates: dict[str, Any]) -> dict[str, Any]` + - `property synced_entities -> dict[str, dict[str, Any]]` + - `async def async_set_synced_entities(self, entities: dict[str, dict[str, Any]]) -> None` + - `property blocklist -> list[str]` + - `async def async_set_blocklist(self, patterns: list[str]) -> None` + - `property ai_settings -> dict[str, Any]` + - `async def async_set_ai_settings(self, settings: dict[str, Any]) -> None` + - `async def async_has_legacy_yaml(self) -> bool` + - `async def async_migrate_and_disable_yaml(self) -> dict[str, Any]` + - `async def async_get_store(hass: HomeAssistant) -> ConsoleStore` + +- [ ] **Step 1: Write the failing tests in `tests/test_store.py`** + +```python +import json +import pytest +from unittest.mock import MagicMock +from custom_components.google_assistant_entity_console.store import ConsoleStore + + +@pytest.mark.asyncio +async def test_store_auto_migrates_legacy_files_and_yaml(tmp_path): + """Test that ConsoleStore imports legacy JSON files, secrets.yaml, SERVICE_ACCOUNT.json, and gaGen YAML on first load.""" + # 1. Create legacy blocklist and AI settings JSON + (tmp_path / "google_assistant_entity_console_blocklist.json").write_text( + json.dumps([r"^sensor\.ignored_.*"]), encoding="utf-8" + ) + (tmp_path / "google_assistant_entity_console_ai_settings.json").write_text( + json.dumps({"ai_source": "home_assistant", "ha_agent_id": "conversation.gemini"}), + encoding="utf-8", + ) + + # 2. Create secrets.yaml and SERVICE_ACCOUNT.json + (tmp_path / "secrets.yaml").write_text( + "googleassistant_projectName: my-home-project-123\ngoogle_device_pin: '4321'\n", + encoding="utf-8", + ) + service_account_data = { + "type": "service_account", + "project_id": "my-home-project-123", + "client_email": "homegraph@my-home-project-123.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC\n-----END PRIVATE KEY-----\n", + } + (tmp_path / "SERVICE_ACCOUNT.json").write_text( + json.dumps(service_account_data), encoding="utf-8" + ) + + # 3. Create gaGen_101726.yaml and configuration.yaml + (tmp_path / "gaGen_101726.yaml").write_text( + "project_id: !secret googleassistant_projectName\n" + "service_account: !include SERVICE_ACCOUNT.json\n" + "report_state: true\n" + "secure_devices_pin: !secret google_device_pin\n" + "expose_by_default: false\n" + "entity_config:\n" + " light.kitchen:\n" + " expose: true\n" + " name: Kitchen Light\n" + " aliases:\n" + " - Kitchen Lamp\n" + " room: Kitchen\n", + encoding="utf-8", + ) + (tmp_path / "configuration.yaml").write_text( + "homeassistant:\n name: Home\ngoogle_assistant: !include gaGen_101726.yaml\n", + encoding="utf-8", + ) + + hass = MagicMock() + hass.config.path.side_effect = lambda *parts: str(tmp_path.joinpath(*parts)) + async def async_add_executor_job(func, *args): + return func(*args) + hass.async_add_executor_job = async_add_executor_job + + store = ConsoleStore(hass) + # Stub underlying HA Store load/save in memory + saved_payloads = [] + async def fake_store_load(): + return saved_payloads[-1] if saved_payloads else None + async def fake_store_save(data): + saved_payloads.append(json.loads(json.dumps(data))) + store._store.async_load = fake_store_load + store._store.async_save = fake_store_save + + await store.async_load() + + assert store.blocklist == [r"^sensor\.ignored_.*"] + assert store.ai_settings["ha_agent_id"] == "conversation.gemini" + assert store.google_settings["project_id"] == "my-home-project-123" + assert store.google_settings["secure_devices_pin"] == "4321" + assert store.google_settings["report_state"] is True + assert store.google_settings["service_account"]["client_email"] == "homegraph@my-home-project-123.iam.gserviceaccount.com" + assert "light.kitchen" in store.synced_entities + assert store.synced_entities["light.kitchen"]["name"] == "Kitchen Light" + assert await store.async_has_legacy_yaml() is True + + # Test commenting out google_assistant in configuration.yaml + mig_res = await store.async_migrate_and_disable_yaml() + assert mig_res["commented_out"] is True + assert await store.async_has_legacy_yaml() is False + updated_conf = (tmp_path / "configuration.yaml").read_text(encoding="utf-8") + assert "# google_assistant: !include gaGen_101726.yaml" in updated_conf +``` + +- [ ] **Step 2: Run `pytest tests/test_store.py` to verify it fails** + +Run: `pytest tests/test_store.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'custom_components.google_assistant_entity_console.store'` + +- [ ] **Step 3: Update `const.py` and implement `custom_components/google_assistant_entity_console/store.py`** + +Update `custom_components/google_assistant_entity_console/const.py`: +```python +DOMAIN = "google_assistant_entity_console" +STORAGE_KEY = "google_assistant_entity_console" +STORAGE_VERSION = 1 +DATA_STORE = "store" +DATA_RUNTIME = "runtime" +``` + +Create `custom_components/google_assistant_entity_console/store.py`: +```python +"""Native Home Assistant .storage manager for Google Assistant Entity Console.""" + +from __future__ import annotations + +import copy +import json +import logging +import os +import re +from typing import Any + +import yaml +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import DATA_STORE, DOMAIN, STORAGE_KEY, STORAGE_VERSION + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_GOOGLE_SETTINGS: dict[str, Any] = { + "project_id": "", + "service_account": {}, + "report_state": True, + "secure_devices_pin": "", + "auto_sync": False, +} + + +def _default_data() -> dict[str, Any]: + return { + "google_settings": copy.deepcopy(DEFAULT_GOOGLE_SETTINGS), + "synced_entities": {}, + "blocklist": [], + "ai_settings": {}, + } + + +def _sync_read_text(path: str) -> str | None: + if not os.path.exists(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except Exception as err: + _LOGGER.warning("Could not read %s: %s", path, err) + return None + + +def _sync_read_json(path: str, default: Any) -> Any: + text = _sync_read_text(path) + if not text: + return default + try: + return json.loads(text) + except Exception as err: + _LOGGER.warning("Could not parse JSON from %s: %s", path, err) + return default + + +def _sync_load_secrets(config_dir_getter) -> dict[str, Any]: + secrets_path = config_dir_getter("secrets.yaml") + text = _sync_read_text(secrets_path) + if not text: + return {} + try: + data = yaml.safe_load(text) + return data if isinstance(data, dict) else {} + except Exception as err: + _LOGGER.warning("Failed to parse secrets.yaml: %s", err) + return {} + + +def _sync_extract_yaml_import(config_dir_getter) -> dict[str, Any]: + """Read configuration.yaml + included gaGen/google_assistant YAML and resolve !secret / !include.""" + config_path = config_dir_getter("configuration.yaml") + config_text = _sync_read_text(config_path) + if not config_text: + return {} + + secrets = _sync_load_secrets(config_dir_getter) + + class ResolverLoader(yaml.SafeLoader): + pass + + def _secret_ctor(loader, node): + key = str(node.value).strip() + return secrets.get(key, f"!secret {key}") + + def _include_ctor(loader, node): + inc_file = str(node.value).strip() + inc_path = config_dir_getter(inc_file) + if inc_file.lower().endswith(".json"): + return _sync_read_json(inc_path, {}) + inc_text = _sync_read_text(inc_path) + if not inc_text: + return {} + return yaml.load(inc_text, Loader=ResolverLoader) + + ResolverLoader.add_constructor("!secret", _secret_ctor) + ResolverLoader.add_constructor("!include", _include_ctor) + + # Check for `google_assistant: !include ` first so we don't fail on unrelated HA tags in configuration.yaml + include_match = re.search( + r"^[ \t]*google_assistant:\s*!include\s+([^\s#]+)", + config_text, + flags=re.MULTILINE, + ) + ga_data: dict[str, Any] | None = None + if include_match: + inc_filename = include_match.group(1).strip() + inc_text = _sync_read_text(config_dir_getter(inc_filename)) + if inc_text: + try: + loaded = yaml.load(inc_text, Loader=ResolverLoader) + if isinstance(loaded, dict): + ga_data = loaded + except Exception as err: + _LOGGER.warning("Failed to parse included %s: %s", inc_filename, err) + else: + try: + full_loaded = yaml.load(config_text, Loader=ResolverLoader) + if isinstance(full_loaded, dict) and isinstance(full_loaded.get("google_assistant"), dict): + ga_data = full_loaded["google_assistant"] + except Exception: + ga_data = None + + if not isinstance(ga_data, dict): + return {} + + google_settings: dict[str, Any] = {} + if project_id := ga_data.get("project_id"): + if not str(project_id).startswith("!secret "): + google_settings["project_id"] = str(project_id) + if "report_state" in ga_data: + google_settings["report_state"] = bool(ga_data.get("report_state")) + if pin := ga_data.get("secure_devices_pin"): + if not str(pin).startswith("!secret "): + google_settings["secure_devices_pin"] = str(pin) + if isinstance(ga_data.get("service_account"), dict): + sa = ga_data["service_account"] + if sa.get("client_email") and sa.get("private_key"): + google_settings["service_account"] = sa + + synced_entities: dict[str, dict[str, Any]] = {} + entity_cfg = ga_data.get("entity_config") + if isinstance(entity_cfg, dict): + for ent_id, cfg in entity_cfg.items(): + if isinstance(cfg, dict) and cfg.get("expose", False): + synced_entities[str(ent_id)] = { + "name": cfg.get("name"), + "aliases": list(cfg.get("aliases") or []), + "room": cfg.get("room"), + } + + return { + "google_settings": google_settings, + "synced_entities": synced_entities, + } + + +def _sync_has_active_google_assistant_yaml(config_dir_getter) -> bool: + config_text = _sync_read_text(config_dir_getter("configuration.yaml")) + if not config_text: + return False + return bool( + re.search(r"^[ \t]*google_assistant\s*:", config_text, flags=re.MULTILINE) + ) + + +def _sync_comment_out_google_assistant_yaml(config_dir_getter) -> bool: + config_path = config_dir_getter("configuration.yaml") + config_text = _sync_read_text(config_path) + if not config_text: + return False + + pattern = r"^([ \t]*)(google_assistant\s*:\s*!include\s+[^\r\n]+)" + if re.search(pattern, config_text, flags=re.MULTILINE): + updated = re.sub( + pattern, + r"\1# \2 # Migrated to .storage by Google Assistant Entity Console", + config_text, + flags=re.MULTILINE, + ) + with open(config_path, "w", encoding="utf-8") as f: + f.write(updated) + return True + return False + + +class ConsoleStore: + """Manage persistent .storage state for Google Assistant Entity Console.""" + + def __init__(self, hass: HomeAssistant) -> None: + self.hass = hass + self._store: Store[dict[str, Any]] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + self._data: dict[str, Any] = _default_data() + self._loaded = False + + async def async_load(self) -> dict[str, Any]: + """Load store data and auto-migrate legacy files if needed.""" + stored = await self._store.async_load() + dirty = False + + if not isinstance(stored, dict): + stored = _default_data() + dirty = True + + for k, default_val in _default_data().items(): + if k not in stored or not isinstance(stored[k], type(default_val)): + stored[k] = copy.deepcopy(default_val) + dirty = True + + for k, v in DEFAULT_GOOGLE_SETTINGS.items(): + if k not in stored["google_settings"]: + stored["google_settings"][k] = copy.deepcopy(v) + dirty = True + + # Migrate legacy blocklist JSON if store blocklist is empty + if not stored["blocklist"]: + legacy_bl = await self.hass.async_add_executor_job( + _sync_read_json, + self.hass.config.path("google_assistant_entity_console_blocklist.json"), + [], + ) + if isinstance(legacy_bl, list) and legacy_bl: + stored["blocklist"] = [str(p) for p in legacy_bl if isinstance(p, str)] + dirty = True + + # Migrate legacy AI settings JSON if store ai_settings is empty + if not stored["ai_settings"]: + legacy_ai = await self.hass.async_add_executor_job( + _sync_read_json, + self.hass.config.path("google_assistant_entity_console_ai_settings.json"), + {}, + ) + if isinstance(legacy_ai, dict) and legacy_ai: + stored["ai_settings"] = legacy_ai + dirty = True + + # Auto-import from configuration.yaml / gaGen YAML if project_id or synced_entities is empty + if not stored["google_settings"].get("project_id") or not stored["synced_entities"]: + yaml_imported = await self.hass.async_add_executor_job( + _sync_extract_yaml_import, self.hass.config.path + ) + imported_gs = yaml_imported.get("google_settings") or {} + for k, val in imported_gs.items(): + if val and not stored["google_settings"].get(k): + stored["google_settings"][k] = val + dirty = True + imported_synced = yaml_imported.get("synced_entities") or {} + if imported_synced and not stored["synced_entities"]: + stored["synced_entities"] = imported_synced + dirty = True + + self._data = stored + self._loaded = True + if dirty: + await self.async_save() + return self._data + + async def async_save(self) -> None: + """Persist current data to .storage.""" + await self._store.async_save(self._data) + + @property + def google_settings(self) -> dict[str, Any]: + return self._data["google_settings"] + + async def async_update_google_settings( + self, updates: dict[str, Any] + ) -> dict[str, Any]: + gs = self._data["google_settings"] + if "project_id" in updates and updates["project_id"] is not None: + gs["project_id"] = str(updates["project_id"]).strip() + if "report_state" in updates and updates["report_state"] is not None: + gs["report_state"] = bool(updates["report_state"]) + if "secure_devices_pin" in updates and updates["secure_devices_pin"] is not None: + gs["secure_devices_pin"] = str(updates["secure_devices_pin"]).strip() + if "auto_sync" in updates and updates["auto_sync"] is not None: + gs["auto_sync"] = bool(updates["auto_sync"]) + if "service_account" in updates and updates["service_account"]: + sa = updates["service_account"] + if isinstance(sa, str): + sa = json.loads(sa) + if isinstance(sa, dict) and sa.get("client_email") and sa.get("private_key"): + gs["service_account"] = sa + else: + raise ValueError("Service account JSON must contain 'client_email' and 'private_key'.") + await self.async_save() + return gs + + @property + def synced_entities(self) -> dict[str, dict[str, Any]]: + return self._data["synced_entities"] + + async def async_set_synced_entities( + self, entities: dict[str, dict[str, Any]] + ) -> None: + self._data["synced_entities"] = { + ent_id: { + "name": cfg.get("name"), + "aliases": list(cfg.get("aliases") or []), + "room": cfg.get("room"), + } + for ent_id, cfg in entities.items() + } + await self.async_save() + + @property + def blocklist(self) -> list[str]: + return self._data["blocklist"] + + async def async_set_blocklist(self, patterns: list[str]) -> None: + self._data["blocklist"] = list(patterns) + await self.async_save() + + @property + def ai_settings(self) -> dict[str, Any]: + return self._data["ai_settings"] + + async def async_set_ai_settings(self, settings: dict[str, Any]) -> None: + self._data["ai_settings"] = dict(settings) + await self.async_save() + + async def async_has_legacy_yaml(self) -> bool: + return await self.hass.async_add_executor_job( + _sync_has_active_google_assistant_yaml, self.hass.config.path + ) + + async def async_migrate_and_disable_yaml(self) -> dict[str, Any]: + """Import any remaining YAML settings and comment out google_assistant: in configuration.yaml.""" + yaml_imported = await self.hass.async_add_executor_job( + _sync_extract_yaml_import, self.hass.config.path + ) + imported_gs = yaml_imported.get("google_settings") or {} + for k, val in imported_gs.items(): + if val and not self._data["google_settings"].get(k): + self._data["google_settings"][k] = val + imported_synced = yaml_imported.get("synced_entities") or {} + if imported_synced and not self._data["synced_entities"]: + self._data["synced_entities"] = imported_synced + await self.async_save() + + commented_out = await self.hass.async_add_executor_job( + _sync_comment_out_google_assistant_yaml, self.hass.config.path + ) + return { + "commented_out": commented_out, + "project_id": self._data["google_settings"].get("project_id", ""), + } + + +async def async_get_store(hass: HomeAssistant) -> ConsoleStore: + """Get or initialize the singleton ConsoleStore in hass.data.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + store: ConsoleStore | None = domain_data.get(DATA_STORE) + if store is None: + store = ConsoleStore(hass) + await store.async_load() + domain_data[DATA_STORE] = store + return store +``` + +- [ ] **Step 4: Run `pytest tests/test_store.py -v` to verify it passes** + +Run: `pytest tests/test_store.py -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 1** + +```bash +git add custom_components/google_assistant_entity_console/const.py custom_components/google_assistant_entity_console/store.py tests/test_store.py +git commit -m "feat(store): add .storage ConsoleStore with automatic YAML and JSON migration" +``` + +--- + +### Task 2: Zero-Reboot Runtime Manager (`runtime.py`) + +**Files:** +- Create: `custom_components/google_assistant_entity_console/runtime.py` +- Create: `tests/test_runtime.py` + +**Interfaces:** +- Consumes: + - `ConsoleStore` from `custom_components/google_assistant_entity_console/store.py` + - `GoogleConfig`, `GoogleAssistantView` from `homeassistant.components.google_assistant.http` + - `DATA_CONFIG`, `DEFAULT_EXPOSED_DOMAINS`, `DOMAIN as GA_DOMAIN`, `SERVICE_REQUEST_SYNC` from `homeassistant.components.google_assistant.const` +- Produces: + - `class GoogleAssistantRuntimeManager`: + - `mode: str` (`"storage"` | `"yaml_attached"` | `"unconfigured"`) + - `google_config: GoogleConfig | None` + - `async def async_initialize(self, entity_config: dict[str, dict[str, Any]]) -> None` + - `async def async_refresh_memory_config(self, entity_config: dict[str, dict[str, Any]], schedule_auto_sync: bool = False) -> None` + - `async def async_sync_to_google(self, entity_config: dict[str, dict[str, Any]]) -> dict[str, Any]` + - `def get_status_summary(self) -> dict[str, Any]` + - `async def async_get_runtime(hass: HomeAssistant) -> GoogleAssistantRuntimeManager` + +- [ ] **Step 1: Write the failing unit tests in `tests/test_runtime.py`** + +```python +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.config_entries import ConfigEntryState +from custom_components.google_assistant_entity_console.runtime import ( + GoogleAssistantRuntimeManager, +) + + +@pytest.mark.asyncio +async def test_runtime_attaches_to_existing_yaml_google_config(): + """Verify runtime attaches to an active google_assistant ConfigEntry and mutates _config in place.""" + hass = MagicMock() + hass.data = {} + mock_google_config = MagicMock() + mock_google_config._config = {"project_id": "yaml-proj", "entity_config": {}} + mock_google_config.async_get_agent_users.return_value = {"agent-1": {}} + mock_google_config.async_sync_entities_all = AsyncMock(return_value=200) + mock_google_config.async_schedule_google_sync_all = MagicMock() + + mock_entry = MagicMock() + mock_entry.state = ConfigEntryState.LOADED + mock_entry.runtime_data = mock_google_config + hass.config_entries.async_entries.return_value = [mock_entry] + + mock_store = MagicMock() + mock_store.google_settings = { + "project_id": "yaml-proj", + "service_account": {"client_email": "a@b.c", "private_key": "key"}, + "report_state": True, + "secure_devices_pin": "1234", + "auto_sync": True, + } + mock_store.async_set_synced_entities = AsyncMock() + + runtime = GoogleAssistantRuntimeManager(hass, mock_store) + initial_entities = {"light.office": {"expose": True, "name": "Office Light"}} + await runtime.async_initialize(initial_entities) + + assert runtime.mode == "yaml_attached" + assert mock_google_config._config["entity_config"] == initial_entities + + # Mutate in-memory config with auto_sync enabled + updated_entities = { + "light.office": {"expose": True, "name": "Office Main Light"}, + "switch.fan": {"expose": True, "name": "Office Fan"}, + } + await runtime.async_refresh_memory_config(updated_entities, schedule_auto_sync=True) + assert mock_google_config._config["entity_config"] == updated_entities + mock_google_config.async_schedule_google_sync_all.assert_called_once() + + # Trigger immediate sync to Google + result = await runtime.async_sync_to_google(updated_entities) + assert result["success"] is True + assert result["status_code"] == 200 + assert result["exposed_count"] == 2 + mock_google_config.async_sync_entities_all.assert_awaited_once() + mock_store.async_set_synced_entities.assert_awaited_once_with(updated_entities) + + +@pytest.mark.asyncio +async def test_runtime_bootstraps_standalone_storage_mode(): + """Verify runtime initializes GoogleConfig and GoogleAssistantView directly from .storage when no YAML entry is loaded.""" + hass = MagicMock() + hass.data = {} + hass.config_entries.async_entries.return_value = [] + hass.services.has_service.return_value = False + + mock_store = MagicMock() + mock_store.google_settings = { + "project_id": "storage-proj-1", + "service_account": {"client_email": "sa@proj.iam.gserviceaccount.com", "private_key": "pem"}, + "report_state": True, + "secure_devices_pin": "9999", + "auto_sync": False, + } + mock_store.async_set_synced_entities = AsyncMock() + + with patch( + "custom_components.google_assistant_entity_console.runtime.GoogleConfig" + ) as mock_gc_cls, patch( + "custom_components.google_assistant_entity_console.runtime.GoogleAssistantView" + ) as mock_view_cls: + mock_gc_instance = MagicMock() + mock_gc_instance._config = {} + mock_gc_instance.async_initialize = AsyncMock() + mock_gc_instance.should_report_state = True + mock_gc_instance.is_reporting_state = False + mock_gc_instance.async_get_agent_users.return_value = {} + mock_gc_cls.return_value = mock_gc_instance + + runtime = GoogleAssistantRuntimeManager(hass, mock_store) + await runtime.async_initialize({"light.kitchen": {"expose": True, "name": "Kitchen"}}) + + assert runtime.mode == "storage" + mock_gc_instance.async_initialize.assert_awaited_once() + hass.http.register_view.assert_called_once_with(mock_view_cls.return_value) + mock_gc_instance.async_enable_report_state.assert_called_once() +``` + +- [ ] **Step 2: Run `pytest tests/test_runtime.py -v` to verify it fails** + +Run: `pytest tests/test_runtime.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'custom_components.google_assistant_entity_console.runtime'` + +- [ ] **Step 3: Implement `custom_components/google_assistant_entity_console/runtime.py`** + +Create `custom_components/google_assistant_entity_console/runtime.py`: +```python +"""Zero-reboot runtime manager bridging .storage with HA Core's GoogleConfig.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.google_assistant.const import ( + CONF_ENTITY_CONFIG, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + CONF_PROJECT_ID, + CONF_REPORT_STATE, + CONF_SECURE_DEVICES_PIN, + CONF_SERVICE_ACCOUNT, + DATA_CONFIG, + DEFAULT_EXPOSED_DOMAINS, + DOMAIN as GA_DOMAIN, + SERVICE_REQUEST_SYNC, +) +from homeassistant.components.google_assistant.http import ( + GoogleAssistantView, + GoogleConfig, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall + +from .const import DATA_RUNTIME, DOMAIN +from .store import ConsoleStore, async_get_store + +_LOGGER = logging.getLogger(__name__) +_VIEW_REGISTERED_KEY = f"{DOMAIN}_ga_view_registered" + + +def _has_valid_service_account(sa: Any) -> bool: + return bool( + isinstance(sa, dict) + and sa.get("client_email") + and sa.get("private_key") + ) + + +class GoogleAssistantRuntimeManager: + """Manage live GoogleConfig state and HomeGraph syncing without HA restarts.""" + + def __init__(self, hass: HomeAssistant, store: ConsoleStore) -> None: + self.hass = hass + self.store = store + self.google_config: GoogleConfig | None = None + self.mode: str = "unconfigured" + self._view: GoogleAssistantView | None = None + + def _build_full_config_dict( + self, entity_config: dict[str, dict[str, Any]] + ) -> dict[str, Any]: + gs = self.store.google_settings + cfg: dict[str, Any] = { + CONF_PROJECT_ID: gs.get("project_id", ""), + CONF_EXPOSE_BY_DEFAULT: False, + CONF_EXPOSED_DOMAINS: list(DEFAULT_EXPOSED_DOMAINS), + CONF_ENTITY_CONFIG: dict(entity_config), + CONF_REPORT_STATE: bool(gs.get("report_state", True)), + } + if gs.get("secure_devices_pin"): + cfg[CONF_SECURE_DEVICES_PIN] = str(gs["secure_devices_pin"]) + if _has_valid_service_account(gs.get("service_account")): + cfg[CONF_SERVICE_ACCOUNT] = dict(gs["service_account"]) + return cfg + + async def async_initialize( + self, entity_config: dict[str, dict[str, Any]] + ) -> None: + """Attach to existing YAML GoogleConfig or bootstrap standalone .storage GoogleConfig.""" + ga_entries = self.hass.config_entries.async_entries(GA_DOMAIN) + for entry in ga_entries: + if ( + entry.state == ConfigEntryState.LOADED + and getattr(entry, "runtime_data", None) is not None + ): + self.google_config = entry.runtime_data + self.mode = "yaml_attached" + await self.async_refresh_memory_config(entity_config) + _LOGGER.info( + "Attached to active google_assistant config entry in memory (%d exposed entities)", + len(entity_config), + ) + return + + # Clean up any broken/unloaded SOURCE_IMPORT entries left over after removing YAML + for entry in list(ga_entries): + if entry.source == SOURCE_IMPORT and entry.state != ConfigEntryState.LOADED: + try: + await self.hass.config_entries.async_remove(entry.entry_id) + except Exception as err: + _LOGGER.debug("Could not remove stale google_assistant import entry: %s", err) + + project_id = self.store.google_settings.get("project_id", "").strip() + if not project_id: + self.mode = "unconfigured" + return + + config = self._build_full_config_dict(entity_config) + self.hass.data[DATA_CONFIG] = config + + if self.google_config is None: + self.google_config = GoogleConfig(self.hass, config) + await self.google_config.async_initialize() + else: + self.google_config._config = config + + # Register /api/google_assistant HTTP endpoint once + existing_view = self.hass.data.get(_VIEW_REGISTERED_KEY) + if existing_view is None: + view = GoogleAssistantView(self.google_config) + self.hass.http.register_view(view) + self.hass.data[_VIEW_REGISTERED_KEY] = view + self._view = view + else: + existing_view.config = self.google_config + self._view = existing_view + + if ( + self.google_config.should_report_state + and _has_valid_service_account(config.get(CONF_SERVICE_ACCOUNT)) + and not self.google_config.is_reporting_state + ): + self.google_config.async_enable_report_state() + + if not self.hass.services.has_service(GA_DOMAIN, SERVICE_REQUEST_SYNC): + async def _handle_request_sync(call: ServiceCall) -> None: + if self.google_config is None: + return + agent_user_id = call.data.get("agent_user_id") or call.context.user_id + if agent_user_id: + await self.google_config.async_sync_entities(agent_user_id) + else: + await self.google_config.async_sync_entities_all() + + self.hass.services.async_register( + GA_DOMAIN, SERVICE_REQUEST_SYNC, _handle_request_sync + ) + + self.mode = "storage" + _LOGGER.info( + "Initialized standalone .storage GoogleConfig for project '%s' (%d exposed entities)", + project_id, + len(entity_config), + ) + + async def async_switch_to_storage_mode( + self, entity_config: dict[str, dict[str, Any]] + ) -> None: + """Detach from legacy SOURCE_IMPORT entry if present and switch to standalone .storage mode.""" + ga_entries = self.hass.config_entries.async_entries(GA_DOMAIN) + for entry in list(ga_entries): + if entry.source == SOURCE_IMPORT: + if getattr(entry, "runtime_data", None) is not None: + self.google_config = entry.runtime_data + try: + await self.hass.config_entries.async_remove(entry.entry_id) + except Exception as err: + _LOGGER.debug("Note while removing legacy import entry: %s", err) + self.mode = "storage" + await self.async_refresh_memory_config(entity_config) + + async def async_refresh_memory_config( + self, + entity_config: dict[str, dict[str, Any]], + schedule_auto_sync: bool = False, + ) -> None: + """Mutate GoogleConfig._config in memory so changes take effect immediately.""" + if self.google_config is None: + if self.store.google_settings.get("project_id"): + await self.async_initialize(entity_config) + return + + cfg = self.google_config._config + cfg[CONF_ENTITY_CONFIG] = dict(entity_config) + + gs = self.store.google_settings + if gs.get("project_id"): + cfg[CONF_PROJECT_ID] = gs["project_id"] + if gs.get("secure_devices_pin"): + cfg[CONF_SECURE_DEVICES_PIN] = str(gs["secure_devices_pin"]) + elif CONF_SECURE_DEVICES_PIN in cfg and not gs.get("secure_devices_pin"): + cfg.pop(CONF_SECURE_DEVICES_PIN, None) + + if _has_valid_service_account(gs.get("service_account")): + if cfg.get(CONF_SERVICE_ACCOUNT) != gs["service_account"]: + cfg[CONF_SERVICE_ACCOUNT] = dict(gs["service_account"]) + self.google_config._access_token = None + + cfg[CONF_REPORT_STATE] = bool(gs.get("report_state", True)) + if ( + cfg[CONF_REPORT_STATE] + and _has_valid_service_account(cfg.get(CONF_SERVICE_ACCOUNT)) + ): + if not self.google_config.is_reporting_state: + self.google_config.async_enable_report_state() + else: + if self.google_config.is_reporting_state: + self.google_config.async_disable_report_state() + + if ( + schedule_auto_sync + and gs.get("auto_sync", False) + and _has_valid_service_account(cfg.get(CONF_SERVICE_ACCOUNT)) + and self.google_config.async_get_agent_users() + ): + self.google_config.async_schedule_google_sync_all() + + async def async_sync_to_google( + self, entity_config: dict[str, dict[str, Any]] + ) -> dict[str, Any]: + """Update in-memory GoogleConfig and trigger live HomeGraph Request Sync.""" + await self.async_refresh_memory_config(entity_config, schedule_auto_sync=False) + + if self.google_config is None: + await self.store.async_set_synced_entities(entity_config) + return { + "success": True, + "status_code": 204, + "exposed_count": len(entity_config), + "agent_users_count": 0, + "mode": self.mode, + "synced_offline": True, + "message": "Saved in .storage. Configure Google Project ID & Service Account in Settings to enable cloud sync.", + } + + agent_users = list(self.google_config.async_get_agent_users()) + has_sa = _has_valid_service_account( + self.google_config._config.get(CONF_SERVICE_ACCOUNT) + ) + + if not agent_users or not has_sa: + await self.store.async_set_synced_entities(entity_config) + return { + "success": True, + "status_code": 204, + "exposed_count": len(entity_config), + "agent_users_count": len(agent_users), + "mode": self.mode, + "synced_offline": True, + "message": ( + "Updated live in memory & .storage. Say 'Hey Google, sync my devices' " + "to link/sync with Google Home." + ), + } + + status_code = await self.google_config.async_sync_entities_all() + success = status_code in (200, 204) + if success: + await self.store.async_set_synced_entities(entity_config) + + return { + "success": success, + "status_code": int(status_code), + "exposed_count": len(entity_config), + "agent_users_count": len(agent_users), + "mode": self.mode, + "synced_offline": False, + } + + def get_status_summary(self) -> dict[str, Any]: + """Return current runtime status for UI display.""" + agent_users = ( + list(self.google_config.async_get_agent_users()) + if self.google_config is not None + else [] + ) + return { + "mode": self.mode, + "project_id": self.store.google_settings.get("project_id", ""), + "has_service_account": _has_valid_service_account( + self.store.google_settings.get("service_account") + ), + "is_reporting_state": bool( + self.google_config and self.google_config.is_reporting_state + ), + "agent_users_count": len(agent_users), + } + + +async def async_get_runtime( + hass: HomeAssistant, +) -> GoogleAssistantRuntimeManager: + """Get or create the singleton GoogleAssistantRuntimeManager.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + runtime: GoogleAssistantRuntimeManager | None = domain_data.get(DATA_RUNTIME) + if runtime is None: + store = await async_get_store(hass) + runtime = GoogleAssistantRuntimeManager(hass, store) + domain_data[DATA_RUNTIME] = runtime + return runtime +``` + +- [ ] **Step 4: Run `pytest tests/test_runtime.py -v` to verify it passes** + +Run: `pytest tests/test_runtime.py -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 2** + +```bash +git add custom_components/google_assistant_entity_console/runtime.py tests/test_runtime.py +git commit -m "feat(runtime): add zero-reboot GoogleAssistantRuntimeManager for live .storage sync" +``` + +--- + +### Task 3: Backend API Views & Component Setup (`views.py` & `__init__.py`) + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/views.py` +- Modify: `custom_components/google_assistant_entity_console/__init__.py` +- Modify: `tests/test_views.py` +- Modify: `tests/test_blocklist.py` +- Modify: `tests/test_entities.py` + +**Interfaces:** +- Consumes: `async_get_store`, `async_get_runtime` +- Produces: + - `async def async_build_entity_config_map(hass: HomeAssistant) -> dict[str, dict[str, Any]]` + - `SyncView` (`POST /api/google_assistant_entity_console/sync`) and `RebuildView` (`POST /api/google_assistant_entity_console/rebuild` calling the same live sync logic) + - `GoogleSettingsView` (`GET/POST /api/google_assistant_entity_console/google_settings`) + - `MigrateYamlView` (`POST /api/google_assistant_entity_console/migrate_yaml`) + - Updated `EntitiesView`, `UpdateEntityView`, `BlocklistView`, `BlocklistAddView`, `AISettingsView` + +- [ ] **Step 1: Add unit tests for new & updated views in `tests/test_views.py`** + +Add tests to `tests/test_views.py` covering: +1. `POST /api/google_assistant_entity_console/sync` (and `/rebuild`) invoking `runtime.async_sync_to_google` and returning `exposed_count`, `status_code`, and `mode`. +2. `GET / POST /api/google_assistant_entity_console/google_settings` reading/updating `project_id`, `service_account`, `report_state`, `secure_devices_pin`, `auto_sync`, and redacting `private_key` on `GET`. +3. `POST /api/google_assistant_entity_console/migrate_yaml` invoking `store.async_migrate_and_disable_yaml()` and `runtime.async_switch_to_storage_mode()`. +4. `POST /api/google_assistant_entity_console/entities/update` refreshing in-memory `GoogleConfig` via `runtime.async_refresh_memory_config(..., schedule_auto_sync=True)`. + +```python +@pytest.mark.asyncio +async def test_google_settings_and_live_sync_views(hass_mock): + from custom_components.google_assistant_entity_console.views import ( + GoogleSettingsView, + SyncView, + ) + # Test GET/POST on GoogleSettingsView and POST on SyncView +``` + +- [ ] **Step 2: Run `pytest tests/test_views.py -v` to verify the new tests fail** + +Run: `pytest tests/test_views.py -v` +Expected: FAIL with `ImportError: cannot import name 'GoogleSettingsView'` + +- [ ] **Step 3: Update `views.py` and `__init__.py` to wire `ConsoleStore` and `GoogleAssistantRuntimeManager`** + +Key updates in `custom_components/google_assistant_entity_console/views.py`: +1. `async_load_blocklist(hass)` / `async_save_blocklist(hass, blocklist)` delegate to `await async_get_store(hass)` (falling back to file helpers only if `hass.data` is not a dict in minimal unit mocks). +2. `async_load_ai_settings(hass)` / `async_save_ai_settings(hass, settings)` delegate to `await async_get_store(hass)`. +3. In `async_fetch_entities_data(hass)`: + - Compare each entity against `store.synced_entities` (checking membership and whether `display_name`, `aliases`, or `area` changed since last sync) to compute `yaml_exposed` (keeping the field name `yaml_exposed` alongside `synced` for 100% frontend compatibility). +4. Add `async_build_entity_config_map(hass)`: + ```python + async def async_build_entity_config_map(hass: HomeAssistant) -> dict[str, dict[str, Any]]: + entities = await async_fetch_entities_data(hass) + entity_config: dict[str, dict[str, Any]] = {} + for ent in entities: + if not ent["should_expose"]: + continue + cfg: dict[str, Any] = { + "expose": True, + "name": ent["display_name"], + } + if ent["aliases"]: + cfg["aliases"] = list(ent["aliases"]) + if ent["area"] and ent["area"] != "TBA": + cfg["room"] = ent["area"] + entity_config[ent["entity_id"]] = cfg + return entity_config + ``` +5. Update `UpdateEntityView.post`: + - After updating `ent_reg`, call: + ```python + runtime = await async_get_runtime(hass) + entity_config = await async_build_entity_config_map(hass) + await runtime.async_refresh_memory_config(entity_config, schedule_auto_sync=True) + ``` +6. Implement `SyncView` (`/api/google_assistant_entity_console/sync`) and update `RebuildView` (`/api/google_assistant_entity_console/rebuild`) to call `runtime.async_sync_to_google(entity_config)`. +7. Implement `GoogleSettingsView` (`/api/google_assistant_entity_console/google_settings`) and `MigrateYamlView` (`/api/google_assistant_entity_console/migrate_yaml`). +8. In `custom_components/google_assistant_entity_console/__init__.py`: + - Initialize `store = await async_get_store(hass)` and `runtime = await async_get_runtime(hass)` in `async_setup_entry`, call `await runtime.async_initialize(await async_build_entity_config_map(hass))`, and register `SyncView()`, `GoogleSettingsView()`, and `MigrateYamlView()`. + +- [ ] **Step 4: Run all backend unit tests** + +Run: `pytest tests/test_store.py tests/test_runtime.py tests/test_views.py tests/test_blocklist.py tests/test_entities.py tests/test_config_flow.py -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 3** + +```bash +git add custom_components/google_assistant_entity_console/views.py custom_components/google_assistant_entity_console/__init__.py tests/test_views.py tests/test_blocklist.py tests/test_entities.py +git commit -m "feat(views): wire .storage store, live sync endpoint, and Google credentials API" +``` + +--- + +### Task 4: Frontend UI — Live Sync Button & Google Assistant `.storage` Settings (`static/`) + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/static/index.html` +- Modify: `custom_components/google_assistant_entity_console/static/app.js` +- Modify: `custom_components/google_assistant_entity_console/static/style.css` +- Modify: `tests/test_playwright_e2e.py` + +**Interfaces:** +- Consumes: + - `GET /api/google_assistant_entity_console/entities` + - `POST /api/google_assistant_entity_console/sync` + - `GET / POST /api/google_assistant_entity_console/google_settings` + - `POST /api/google_assistant_entity_console/migrate_yaml` +- Produces: + - Header **Sync to Google** button (`#rebuildBtn`) that syncs live and refreshes entity badges without opening `#restartModal`. + - **Google Assistant Connection (`.storage`)** section in `#aiSettingsModal` with inputs: + - `#gaProjectIdInput` + - `#gaServiceAccountInput` & `#gaServiceAccountStatus` + - `#gaPinInput` + - `#gaReportStateToggle` + - `#gaAutoSyncToggle` + - `#migrateYamlBtn` (visible when `has_legacy_yaml` is true) + +- [ ] **Step 1: Add Playwright E2E tests in `tests/test_playwright_e2e.py`** + +Add E2E test cases verifying: +1. Clicking **Sync to Google** calls `/api/google_assistant_entity_console/sync` (or `/rebuild`), clears pending badges immediately, shows a success toast, and does **not** open `#restartModal`. +2. Opening the Settings modal loads Google Assistant `.storage` settings (`project_id`, `client_email` status badge, `report_state`, `auto_sync`), saves updated settings via `POST /api/google_assistant_entity_console/google_settings`, and triggers one-click YAML migration via `#migrateYamlBtn`. + +- [ ] **Step 2: Run Playwright E2E test to verify it fails** + +Run: `pytest tests/test_playwright_e2e.py -v` +Expected: FAIL (new Google Connection settings inputs and live sync behavior not yet in `index.html` / `app.js`) + +- [ ] **Step 3: Update `index.html`, `app.js`, and `style.css`** + +1. In `custom_components/google_assistant_entity_console/static/index.html`: + - Update `#rebuildBtn` text/icon from `Rebuild YAML` to ` Sync to Google`. + - In `#aiSettingsModal`, add the **Google Assistant Connection (`.storage`)** panel above the AI Assistant configuration with `#gaProjectIdInput`, `#gaPinInput`, `#gaServiceAccountInput`, `#gaServiceAccountStatus`, `#gaReportStateToggle`, `#gaAutoSyncToggle`, and `#migrateYamlBtn`. +2. In `custom_components/google_assistant_entity_console/static/app.js`: + - Update header status display (`#configFile`) to show `.storage Live ()` or `YAML Attached ()`. + - Update `handleRebuild()` to call `/api/google_assistant_entity_console/sync`, refresh entities (`await fetchEntities()`), and show a live toast (`Synced ${result.exposed_count} entities to Google HomeGraph!`) without opening `restartModal`. + - Update `loadAiSettings()` and the Settings save handler to also load and save `/api/google_assistant_entity_console/google_settings` and wire `#migrateYamlBtn` to `POST /api/google_assistant_entity_console/migrate_yaml`. + +- [ ] **Step 4: Run full test suite (`pytest`)** + +Run: `pytest -v` +Expected: PASS (all unit and Playwright E2E tests pass) + +- [ ] **Step 5: Commit Task 4** + +```bash +git add custom_components/google_assistant_entity_console/static/index.html custom_components/google_assistant_entity_console/static/app.js custom_components/google_assistant_entity_console/static/style.css tests/test_playwright_e2e.py +git commit -m "feat(ui): replace YAML rebuild/restart flow with live .storage Sync to Google and credentials UI" +``` diff --git a/docs/superpowers/specs/2026-10-17-storage-native-zero-reboot-design.md b/docs/superpowers/specs/2026-10-17-storage-native-zero-reboot-design.md new file mode 100644 index 0000000..10a69ba --- /dev/null +++ b/docs/superpowers/specs/2026-10-17-storage-native-zero-reboot-design.md @@ -0,0 +1,233 @@ +# `.storage`-Native & Zero-Reboot Google Assistant Architecture — Design Spec + +**Date:** 2026-10-17 +**Author:** Steve & Antigravity +**Status:** Approved for Planning + +--- + +## 1. Problem Statement & Goals + +Home Assistant's built-in self-hosted `google_assistant` integration requires YAML configuration (`configuration.yaml` / `!include gaGen_MMDDYY.yaml`) and a full Home Assistant Core restart whenever entity exposure, names, aliases, or credentials change. By contrast, Home Assistant Cloud (Nabu Casa) manages voice assistant entities in `.storage` and syncs changes live to Google HomeGraph without restarting. + +### Primary Goals +1. **100% `.storage` Persistence (Zero `configuration.yaml` Required):** + - Store Google Assistant project credentials (`project_id`, `service_account` JSON, `report_state`, `secure_devices_pin`, `auto_sync`), last-synced entity state (`synced_entities`), regex blocklist (`blocklist`), and AI configuration (`ai_settings`) in Home Assistant's native `.storage/google_assistant_entity_console` via `homeassistant.helpers.storage.Store`. + - Store per-entity exposure (`options["conversation"]["should_expose"]`), display names, and aliases in Home Assistant's native `.storage/core.entity_registry`. +2. **Zero-Reboot Live Runtime & HomeGraph Sync:** + - Manage Home Assistant Core's `GoogleConfig` (`homeassistant.components.google_assistant.http.GoogleConfig`) directly in memory. + - Update `google_config._config["entity_config"]` immediately on every entity update in the Console. + - Replace the two-step **Rebuild YAML + Restart HA** workflow with a single **Sync to Google** action (plus optional 15-second debounced auto-sync) that calls `await google_config.async_sync_entities_all()` (`https://homegraph.googleapis.com/v1/devices:requestSync`) live without restarting Home Assistant. +3. **Seamless Auto-Import & One-Click YAML Migration:** + - Automatically import existing `project_id`, `service_account` (`SERVICE_ACCOUNT.json`), `secure_devices_pin`, `report_state`, `blocklist`, and `ai_settings` from legacy YAML/JSON files on first load. + - Provide a **Google Assistant Connection** section in the Console Settings modal to view/edit credentials and a one-click **Migrate from YAML** button that comments out `google_assistant:` in `configuration.yaml` and removes any legacy `SOURCE_IMPORT` config entry. + +--- + +## 2. System Architecture + +```mermaid +flowchart LR + subgraph Storage["Home Assistant .storage"] + ER[".storage/core.entity_registry\n(name, aliases, should_expose)"] + AR[".storage/core.area_registry\n(room hints)"] + CS[".storage/google_assistant_entity_console\n(credentials, synced_entities, blocklist, ai_settings)"] + GS[".storage/google_assistant\n(agent_user_ids, local webhook IDs)"] + end + + subgraph Console["custom_components/google_assistant_entity_console"] + ST["ConsoleStore (store.py)"] + RT["GoogleAssistantRuntimeManager (runtime.py)"] + VW["HTTP API Views (views.py)"] + UI["Frontend Panel (app.js / index.html)"] + end + + subgraph HACore["homeassistant.components.google_assistant"] + GC["GoogleConfig\n(_config['entity_config'] in RAM)"] + GAV["GoogleAssistantView\n(/api/google_assistant)"] + RS["report_state listener"] + end + + UI <--> VW + VW <--> ST + VW <--> ER + ST <--> CS + RT <--> ST + RT <--> ER + RT <--> AR + RT -->|"Mutates _config & calls async_sync_entities_all()"| GC + GC <--> GS + GC <--> GAV + GC <--> RS +``` + +--- + +## 3. Component Design + +### 3.1 Native Storage Layer (`custom_components/google_assistant_entity_console/store.py`) + +`ConsoleStore` wraps `homeassistant.helpers.storage.Store(hass, STORAGE_VERSION=1, STORAGE_KEY="google_assistant_entity_console")`. + +#### Schema +```python +{ + "google_settings": { + "project_id": "", + "service_account": {}, # {"client_email": "...", "private_key": "...", ...} + "report_state": True, + "secure_devices_pin": "", + "auto_sync": False, + }, + "synced_entities": { + # entity_id -> {"name": str, "aliases": list[str], "room": str | None} + }, + "blocklist": [ + # list of regex strings + ], + "ai_settings": { + # ai_source, ha_agent_id, base_url, api_key, model + }, +} +``` + +#### Automatic First-Run Migration +When `await store.async_load()` runs and finds no existing `.storage/google_assistant_entity_console` file (or empty sections): +1. **Blocklist Migration:** Reads `/config/google_assistant_entity_console_blocklist.json` if present and imports patterns into `data["blocklist"]`. +2. **AI Settings Migration:** Reads `/config/google_assistant_entity_console_ai_settings.json` if present and imports settings into `data["ai_settings"]`. +3. **YAML & Secrets Migration (`async_import_from_yaml`):** + - Inspects `/config/configuration.yaml` for either inline `google_assistant:` or `google_assistant: !include .yaml`. + - Loads `/config/secrets.yaml` to resolve `!secret ` tags (e.g., `googleassistant_projectName`, `google_device_pin`). + - Loads any referenced `!include ` file (e.g., `/config/SERVICE_ACCOUNT.json`) to resolve the service account dictionary (`client_email`, `private_key`, etc.). + - Imports `project_id`, `service_account`, `report_state`, `secure_devices_pin` into `data["google_settings"]`. + - Imports any `entity_config` entries where `expose: true` into `data["synced_entities"]` so existing synced state is preserved immediately without requiring a full re-sync. + +--- + +### 3.2 Zero-Reboot Runtime Manager (`custom_components/google_assistant_entity_console/runtime.py`) + +`GoogleAssistantRuntimeManager` bridges `ConsoleStore` + `core.entity_registry` with Home Assistant Core's `GoogleConfig`. + +#### Building `entity_config` in Memory +`await runtime.async_build_entity_config()` queries `async_fetch_entities_data(hass)` and constructs the dictionary expected by `GoogleConfig`: +```python +{ + entity["entity_id"]: { + "expose": True, + "name": entity["display_name"], + "aliases": entity["aliases"], + **({"room": entity["area"]} if entity["area"] and entity["area"] != "TBA" else {}), + } + for entity in active_entities + if entity["should_expose"] +} +``` + +#### Dual-Mode Bootstrap (`async_initialize`) +1. **Check for Active YAML-Loaded `google_assistant` Entry (`yaml_attached` mode):** + - Checks `hass.config_entries.async_entries("google_assistant")` for an entry with `entry.state == ConfigEntryState.LOADED` and `getattr(entry, "runtime_data", None) is not None`. + - If found: + - Attaches `self.google_config = entry.runtime_data`. + - Updates `self.google_config._config["entity_config"]` in place with the `.storage`-built `entity_config`. + - Sets `self.mode = "yaml_attached"`. +2. **Standalone `.storage` Bootstrap (`storage` mode):** + - If `google_assistant:` is not active via YAML: + - Removes any stale `SOURCE_IMPORT` `google_assistant` config entry whose setup failed or is unloaded so Home Assistant never logs a `KeyError: 'google_assistant_config'` on startup. + - If `project_id` is configured in `store.google_settings`: + - Builds the full runtime config dictionary: + ```python + config = { + "project_id": google_settings["project_id"], + "expose_by_default": False, + "exposed_domains": list(DEFAULT_EXPOSED_DOMAINS), + "entity_config": entity_config, + "report_state": bool(google_settings.get("report_state", True)), + } + if google_settings.get("secure_devices_pin"): + config["secure_devices_pin"] = str(google_settings["secure_devices_pin"]) + if service_account_valid(google_settings.get("service_account")): + config["service_account"] = google_settings["service_account"] + ``` + - Stores `hass.data["google_assistant_config"] = config`. + - Instantiates `GoogleConfig(hass, config)` and awaits `google_config.async_initialize()`. + - Registers `GoogleAssistantView(google_config)` once on `hass.http` (tracked via `hass.data` flag so reloads/re-initializations update the view's `config` reference rather than re-registering `/api/google_assistant`). + - Enables proactive state reporting (`google_config.async_enable_report_state()`) when `report_state` and `service_account` are configured. + - Registers the `google_assistant.request_sync` service if not already registered. + - Sets `self.mode = "storage"`. + +#### Live In-Memory Refresh & HomeGraph Sync +- **`await runtime.async_refresh_memory_config()`**: + - Recomputes `entity_config` from `core.entity_registry` and mutates `self.google_config._config["entity_config"]` in place. + - Also updates `report_state`, `secure_devices_pin`, `project_id`, and `service_account` on `self.google_config._config`, toggling `async_enable_report_state()` / `async_disable_report_state()` if `report_state` changed, and resetting `self.google_config._access_token = None` if `service_account` changed. + - If `store.google_settings["auto_sync"]` is `True` and `self.google_config` has connected `agent_user_ids`, calls `self.google_config.async_schedule_google_sync_all()` (15s debounce). +- **`await runtime.async_sync_to_google()`**: + - Calls `await self.async_refresh_memory_config()`. + - Snapshot-saves the current `entity_config` into `store.async_set_synced_entities(entity_config)` when sync succeeds (or locally if no `agent_user_ids` have linked via the Google Home app yet, returning a clear status indicator). + - If `self.google_config` has connected `agent_user_ids` and a valid `service_account`, calls `status = await self.google_config.async_sync_entities_all()`. + - Returns structured result: + ```python + { + "success": status in (200, 204), + "status_code": status, + "exposed_count": len(entity_config), + "agent_users_count": len(self.google_config.async_get_agent_users()), + "mode": self.mode, + "synced_offline": len(self.google_config.async_get_agent_users()) == 0, + } + ``` + +--- + +### 3.3 HTTP API Endpoints (`custom_components/google_assistant_entity_console/views.py`) + +| Endpoint | Method | Purpose | +|---|---|---| +| `/api/google_assistant_entity_console/entities` | `GET` | Returns `entities` (with `yaml_exposed` populated from `store.synced_entities` so pending diff badges work identically), `storage_mode` (`"storage"` \| `"yaml_attached"` \| `"unconfigured"`), `has_legacy_yaml` (`bool`), `project_id`, and `version`. | +| `/api/google_assistant_entity_console/entities/update` | `POST` | Updates `core.entity_registry`, calls `await runtime.async_refresh_memory_config()`, and triggers debounced auto-sync if enabled. | +| `/api/google_assistant_entity_console/sync` (and `/rebuild` alias) | `POST` | Calls `await runtime.async_sync_to_google()` to push changes to Google HomeGraph immediately without YAML generation or rebooting. | +| `/api/google_assistant_entity_console/google_settings` | `GET` / `POST` | Reads or updates `project_id`, `service_account` (accepts full JSON string or dict; redacts `private_key` on `GET` while returning `client_email` and `has_service_account: bool`), `report_state`, `secure_devices_pin`, and `auto_sync`. Hot-reloads `runtime` immediately. | +| `/api/google_assistant_entity_console/migrate_yaml` | `POST` | Imports credentials from `configuration.yaml` / `secrets.yaml` / `SERVICE_ACCOUNT.json` into `ConsoleStore`, comments out `google_assistant:` in `configuration.yaml`, removes any `SOURCE_IMPORT` `google_assistant` config entry, and switches `runtime` to `"storage"` mode. | +| `/api/google_assistant_entity_console/blocklist` & `/blocklist/add` | `GET` / `POST` | Uses `ConsoleStore` instead of `/config/google_assistant_entity_console_blocklist.json`. | +| `/api/google_assistant_entity_console/ai/settings` | `GET` / `POST` | Uses `ConsoleStore` instead of `/config/google_assistant_entity_console_ai_settings.json`. | + +--- + +### 3.4 Frontend Console Changes (`static/index.html`, `static/app.js`, `static/style.css`) + +1. **Header Bar (`index.html` & `app.js`):** + - Replace the **Rebuild YAML** button label/icon with **Sync to Google** (` Sync to Google`). + - Replace the subtitle `Config: ...` with `Mode: .storage Live Sync` (showing project ID and mode status, plus a clickable **Migrate YAML** chip if `has_legacy_yaml` is true). + - Clicking **Sync to Google** calls `POST /api/google_assistant_entity_console/sync`, refreshes entities so all "Pending Add / Pending Remove" badges transition to "Synced", and displays a toast (`Synced X entities to Google Assistant!`)—never opening `restartModal`. +2. **Settings Modal (`index.html` & `app.js`):** + - Expand the Settings modal with two clean sections/tabs: + - **Google Assistant Connection (`.storage`)**: + - **Project ID** text input + - **Service Account JSON** textarea / file-upload dropzone (shows `Configured: ` badge when a private key is already saved in `.storage`; pasting new JSON replaces it) + - **Secure Devices PIN** password/text input + - **Proactive State Reporting (`report_state`)** checkbox + - **Auto-Sync on Entity Edit (15s debounce)** checkbox + - **Migrate & Comment Out `configuration.yaml`** button (shown when `has_legacy_yaml` is detected) + - **AI Assistant Settings** (existing OpenAI / HA Conversation Agent controls). + +--- + +## 4. Error Handling & Edge Cases + +1. **No Linked Google Account Yet (`agent_user_ids` empty):** + - When a user first sets up Google Assistant before linking their Google Home app, `google_config.async_get_agent_users()` is empty (`204 No Content`). `async_sync_to_google()` still updates `google_config._config["entity_config"]` in memory and marks `store.synced_entities` up to date, returning `synced_offline: True` so the UI informs the user: *"Saved X entities in memory & .storage (link or say 'Hey Google, sync my devices' in Google Home to complete initial account linking)."* +2. **Missing or Invalid Service Account:** + - If `service_account` is missing or Google returns `401`/`403`/`404`, `async_sync_to_google()` still updates in-memory `entity_config` (so voice command `Hey Google, sync my devices` works via webhook!) and returns a clear warning message in the JSON response. +3. **Entity Value Changes (Name / Aliases / Area) While Already Exposed:** + - `async_fetch_entities_data()` compares the current entity's `(display_name, sorted(aliases), area)` against `store.synced_entities[entity_id]`. If an already-exposed entity's name, aliases, or room changed since the last sync, `yaml_exposed` is reported as `False` (or `pending_update: True`) so the **Sync to Google** button highlights that unsynced metadata changes are waiting to be pushed. + +--- + +## 5. Testing Strategy + +1. **Unit Tests (`tests/test_store.py`, `tests/test_runtime.py`, `tests/test_views.py`):** + - Verify `ConsoleStore` CRUD and automatic migration from legacy `blocklist.json`, `ai_settings.json`, `configuration.yaml`, `secrets.yaml`, and `SERVICE_ACCOUNT.json`. + - Verify `GoogleAssistantRuntimeManager` in both `"yaml_attached"` mode and standalone `"storage"` mode, including in-memory `_config["entity_config"]` mutation and `async_sync_entities_all()` invocation. + - Verify `/api/google_assistant_entity_console/sync`, `/google_settings`, and `/migrate_yaml` endpoints. +2. **Playwright E2E Tests (`tests/test_playwright_e2e.py`):** + - Verify **Sync to Google** button workflow (no restart modal, clears pending badges, shows success toast). + - Verify **Google Assistant Connection** settings tab (loading/saving `project_id`, `service_account`, `secure_devices_pin`, `auto_sync`, and one-click YAML migration). From 316d8acc6eb5c090844612c207800d4d0b09b922 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sat, 17 Oct 2026 15:19:40 -0500 Subject: [PATCH 2/8] feat(store): add .storage ConsoleStore with automatic YAML and JSON migration --- .../google_assistant_entity_console/const.py | 4 + .../manifest.json | 2 +- .../google_assistant_entity_console/store.py | 371 ++++++++++++++++++ tests/conftest.py | 91 ++++- tests/test_store.py | 134 +++++++ 5 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 custom_components/google_assistant_entity_console/store.py create mode 100644 tests/test_store.py diff --git a/custom_components/google_assistant_entity_console/const.py b/custom_components/google_assistant_entity_console/const.py index 90f9122..b71b0d7 100644 --- a/custom_components/google_assistant_entity_console/const.py +++ b/custom_components/google_assistant_entity_console/const.py @@ -1 +1,5 @@ DOMAIN = "google_assistant_entity_console" +STORAGE_KEY = "google_assistant_entity_console" +STORAGE_VERSION = 1 +DATA_STORE = "store" +DATA_RUNTIME = "runtime" diff --git a/custom_components/google_assistant_entity_console/manifest.json b/custom_components/google_assistant_entity_console/manifest.json index a023d69..7372f9a 100644 --- a/custom_components/google_assistant_entity_console/manifest.json +++ b/custom_components/google_assistant_entity_console/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "config_flow": true, "requirements": [], - "version": "2.0.0" + "version": "3.0.0" } diff --git a/custom_components/google_assistant_entity_console/store.py b/custom_components/google_assistant_entity_console/store.py new file mode 100644 index 0000000..d760c9a --- /dev/null +++ b/custom_components/google_assistant_entity_console/store.py @@ -0,0 +1,371 @@ +"""Native Home Assistant .storage manager for Google Assistant Entity Console.""" + +from __future__ import annotations + +import copy +import json +import logging +import os +import re +from typing import Any + +import yaml +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import DATA_STORE, DOMAIN, STORAGE_KEY, STORAGE_VERSION + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_GOOGLE_SETTINGS: dict[str, Any] = { + "project_id": "", + "service_account": {}, + "report_state": True, + "secure_devices_pin": "", + "auto_sync": False, +} + + +def _default_data() -> dict[str, Any]: + return { + "google_settings": copy.deepcopy(DEFAULT_GOOGLE_SETTINGS), + "synced_entities": {}, + "blocklist": [], + "ai_settings": {}, + } + + +def _sync_read_text(path: str) -> str | None: + if not os.path.exists(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except Exception as err: + _LOGGER.warning("Could not read %s: %s", path, err) + return None + + +def _sync_read_json(path: str, default: Any) -> Any: + text = _sync_read_text(path) + if not text: + return default + try: + return json.loads(text) + except Exception as err: + _LOGGER.warning("Could not parse JSON from %s: %s", path, err) + return default + + +def _sync_load_secrets(config_dir_getter) -> dict[str, Any]: + secrets_path = config_dir_getter("secrets.yaml") + text = _sync_read_text(secrets_path) + if not text: + return {} + try: + data = yaml.safe_load(text) + return data if isinstance(data, dict) else {} + except Exception as err: + _LOGGER.warning("Failed to parse secrets.yaml: %s", err) + return {} + + +def _sync_extract_yaml_import(config_dir_getter) -> dict[str, Any]: + """Read configuration.yaml + included gaGen/google_assistant YAML and resolve !secret / !include.""" + config_path = config_dir_getter("configuration.yaml") + config_text = _sync_read_text(config_path) + if not config_text: + return {} + + secrets = _sync_load_secrets(config_dir_getter) + + class ResolverLoader(yaml.SafeLoader): + pass + + def _secret_ctor(loader, node): + key = str(node.value).strip() + return secrets.get(key, f"!secret {key}") + + def _include_ctor(loader, node): + inc_file = str(node.value).strip() + inc_path = config_dir_getter(inc_file) + if inc_file.lower().endswith(".json"): + return _sync_read_json(inc_path, {}) + inc_text = _sync_read_text(inc_path) + if not inc_text: + return {} + return yaml.load(inc_text, Loader=ResolverLoader) + + ResolverLoader.add_constructor("!secret", _secret_ctor) + ResolverLoader.add_constructor("!include", _include_ctor) + + include_match = re.search( + r"^[ \t]*google_assistant:\s*!include\s+([^\s#]+)", + config_text, + flags=re.MULTILINE, + ) + ga_data: dict[str, Any] | None = None + if include_match: + inc_filename = include_match.group(1).strip() + inc_text = _sync_read_text(config_dir_getter(inc_filename)) + if inc_text: + try: + loaded = yaml.load(inc_text, Loader=ResolverLoader) + if isinstance(loaded, dict): + ga_data = loaded + except Exception as err: + _LOGGER.warning("Failed to parse included %s: %s", inc_filename, err) + else: + try: + full_loaded = yaml.load(config_text, Loader=ResolverLoader) + if isinstance(full_loaded, dict) and isinstance( + full_loaded.get("google_assistant"), dict + ): + ga_data = full_loaded["google_assistant"] + except Exception: + ga_data = None + + if not isinstance(ga_data, dict): + return {} + + google_settings: dict[str, Any] = {} + if project_id := ga_data.get("project_id"): + if not str(project_id).startswith("!secret "): + google_settings["project_id"] = str(project_id) + if "report_state" in ga_data: + google_settings["report_state"] = bool(ga_data.get("report_state")) + if pin := ga_data.get("secure_devices_pin"): + if not str(pin).startswith("!secret "): + google_settings["secure_devices_pin"] = str(pin) + if isinstance(ga_data.get("service_account"), dict): + sa = ga_data["service_account"] + if sa.get("client_email") and sa.get("private_key"): + google_settings["service_account"] = sa + + synced_entities: dict[str, dict[str, Any]] = {} + entity_cfg = ga_data.get("entity_config") + if isinstance(entity_cfg, dict): + for ent_id, cfg in entity_cfg.items(): + if isinstance(cfg, dict) and cfg.get("expose", False): + synced_entities[str(ent_id)] = { + "name": cfg.get("name"), + "aliases": list(cfg.get("aliases") or []), + "room": cfg.get("room"), + } + + return { + "google_settings": google_settings, + "synced_entities": synced_entities, + } + + +def _sync_has_active_google_assistant_yaml(config_dir_getter) -> bool: + config_text = _sync_read_text(config_dir_getter("configuration.yaml")) + if not config_text: + return False + return bool( + re.search(r"^[ \t]*google_assistant\s*:", config_text, flags=re.MULTILINE) + ) + + +def _sync_comment_out_google_assistant_yaml(config_dir_getter) -> bool: + config_path = config_dir_getter("configuration.yaml") + config_text = _sync_read_text(config_path) + if not config_text: + return False + + pattern = r"^([ \t]*)(google_assistant\s*:\s*!include\s+[^\r\n]+)" + if re.search(pattern, config_text, flags=re.MULTILINE): + updated = re.sub( + pattern, + r"\1# \2 # Migrated to .storage by Google Assistant Entity Console", + config_text, + flags=re.MULTILINE, + ) + with open(config_path, "w", encoding="utf-8") as f: + f.write(updated) + return True + return False + + +class ConsoleStore: + """Manage persistent .storage state for Google Assistant Entity Console.""" + + def __init__(self, hass: HomeAssistant) -> None: + self.hass = hass + self._store: Store[dict[str, Any]] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + self._data: dict[str, Any] = _default_data() + self._loaded = False + + async def async_load(self) -> dict[str, Any]: + """Load store data and auto-migrate legacy files if needed.""" + stored = await self._store.async_load() + dirty = False + + if not isinstance(stored, dict): + stored = _default_data() + dirty = True + + for k, default_val in _default_data().items(): + if k not in stored or not isinstance(stored[k], type(default_val)): + stored[k] = copy.deepcopy(default_val) + dirty = True + + for k, v in DEFAULT_GOOGLE_SETTINGS.items(): + if k not in stored["google_settings"]: + stored["google_settings"][k] = copy.deepcopy(v) + dirty = True + + # Migrate legacy blocklist JSON if store blocklist is empty + if not stored["blocklist"]: + legacy_bl = await self.hass.async_add_executor_job( + _sync_read_json, + self.hass.config.path("google_assistant_entity_console_blocklist.json"), + [], + ) + if isinstance(legacy_bl, list) and legacy_bl: + stored["blocklist"] = [str(p) for p in legacy_bl if isinstance(p, str)] + dirty = True + + # Migrate legacy AI settings JSON if store ai_settings is empty + if not stored["ai_settings"]: + legacy_ai = await self.hass.async_add_executor_job( + _sync_read_json, + self.hass.config.path("google_assistant_entity_console_ai_settings.json"), + {}, + ) + if isinstance(legacy_ai, dict) and legacy_ai: + stored["ai_settings"] = legacy_ai + dirty = True + + # Auto-import from configuration.yaml / gaGen YAML if project_id or synced_entities is empty + if ( + not stored["google_settings"].get("project_id") + or not stored["synced_entities"] + ): + yaml_imported = await self.hass.async_add_executor_job( + _sync_extract_yaml_import, self.hass.config.path + ) + imported_gs = yaml_imported.get("google_settings") or {} + for k, val in imported_gs.items(): + if val and not stored["google_settings"].get(k): + stored["google_settings"][k] = val + dirty = True + imported_synced = yaml_imported.get("synced_entities") or {} + if imported_synced and not stored["synced_entities"]: + stored["synced_entities"] = imported_synced + dirty = True + + self._data = stored + self._loaded = True + if dirty: + await self.async_save() + return self._data + + async def async_save(self) -> None: + """Persist current data to .storage.""" + await self._store.async_save(self._data) + + @property + def google_settings(self) -> dict[str, Any]: + return self._data["google_settings"] + + async def async_update_google_settings( + self, updates: dict[str, Any] + ) -> dict[str, Any]: + gs = self._data["google_settings"] + if "project_id" in updates and updates["project_id"] is not None: + gs["project_id"] = str(updates["project_id"]).strip() + if "report_state" in updates and updates["report_state"] is not None: + gs["report_state"] = bool(updates["report_state"]) + if "secure_devices_pin" in updates and updates["secure_devices_pin"] is not None: + gs["secure_devices_pin"] = str(updates["secure_devices_pin"]).strip() + if "auto_sync" in updates and updates["auto_sync"] is not None: + gs["auto_sync"] = bool(updates["auto_sync"]) + if "service_account" in updates and updates["service_account"]: + sa = updates["service_account"] + if isinstance(sa, str): + sa = json.loads(sa) + if isinstance(sa, dict) and sa.get("client_email") and sa.get("private_key"): + gs["service_account"] = sa + else: + raise ValueError( + "Service account JSON must contain 'client_email' and 'private_key'." + ) + await self.async_save() + return gs + + @property + def synced_entities(self) -> dict[str, dict[str, Any]]: + return self._data["synced_entities"] + + async def async_set_synced_entities( + self, entities: dict[str, dict[str, Any]] + ) -> None: + self._data["synced_entities"] = { + ent_id: { + "name": cfg.get("name"), + "aliases": list(cfg.get("aliases") or []), + "room": cfg.get("room"), + } + for ent_id, cfg in entities.items() + } + await self.async_save() + + @property + def blocklist(self) -> list[str]: + return self._data["blocklist"] + + async def async_set_blocklist(self, patterns: list[str]) -> None: + self._data["blocklist"] = list(patterns) + await self.async_save() + + @property + def ai_settings(self) -> dict[str, Any]: + return self._data["ai_settings"] + + async def async_set_ai_settings(self, settings: dict[str, Any]) -> None: + self._data["ai_settings"] = dict(settings) + await self.async_save() + + async def async_has_legacy_yaml(self) -> bool: + return await self.hass.async_add_executor_job( + _sync_has_active_google_assistant_yaml, self.hass.config.path + ) + + async def async_migrate_and_disable_yaml(self) -> dict[str, Any]: + """Import any remaining YAML settings and comment out google_assistant: in configuration.yaml.""" + yaml_imported = await self.hass.async_add_executor_job( + _sync_extract_yaml_import, self.hass.config.path + ) + imported_gs = yaml_imported.get("google_settings") or {} + for k, val in imported_gs.items(): + if val and not self._data["google_settings"].get(k): + self._data["google_settings"][k] = val + imported_synced = yaml_imported.get("synced_entities") or {} + if imported_synced and not self._data["synced_entities"]: + self._data["synced_entities"] = imported_synced + await self.async_save() + + commented_out = await self.hass.async_add_executor_job( + _sync_comment_out_google_assistant_yaml, self.hass.config.path + ) + return { + "commented_out": commented_out, + "project_id": self._data["google_settings"].get("project_id", ""), + } + + +async def async_get_store(hass: HomeAssistant) -> ConsoleStore: + """Get or initialize the singleton ConsoleStore in hass.data.""" + if not isinstance(getattr(hass, "data", None), dict): + hass.data = {} + domain_data = hass.data.setdefault(DOMAIN, {}) + store: ConsoleStore | None = domain_data.get(DATA_STORE) + if store is None: + store = ConsoleStore(hass) + await store.async_load() + domain_data[DATA_STORE] = store + return store diff --git a/tests/conftest.py b/tests/conftest.py index e022756..fcd3163 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -124,8 +124,15 @@ def async_show_form( ): return {"type": "form", "step_id": step_id, "errors": errors} + class ConfigEntryState: + LOADED = "loaded" + NOT_LOADED = "not_loaded" + SETUP_ERROR = "setup_error" + config_entries.ConfigEntry = ConfigEntry config_entries.ConfigFlow = ConfigFlow + config_entries.ConfigEntryState = ConfigEntryState + config_entries.SOURCE_IMPORT = "import" # components.http http = register_mock("homeassistant.components.http") @@ -160,12 +167,76 @@ def __init__(self, url_path: str | None = None, path: str | None = None, cache_h conversation.async_converse = AsyncMock() conversation.async_get_agent_info = MagicMock() + # components.google_assistant + ga_const = register_mock("homeassistant.components.google_assistant.const") + ga_const.DOMAIN = "google_assistant" + ga_const.DATA_CONFIG = "google_assistant_config" + ga_const.CONF_PROJECT_ID = "project_id" + ga_const.CONF_EXPOSE_BY_DEFAULT = "expose_by_default" + ga_const.CONF_EXPOSED_DOMAINS = "exposed_domains" + ga_const.CONF_ENTITY_CONFIG = "entity_config" + ga_const.CONF_REPORT_STATE = "report_state" + ga_const.CONF_SECURE_DEVICES_PIN = "secure_devices_pin" + ga_const.CONF_SERVICE_ACCOUNT = "service_account" + ga_const.DEFAULT_EXPOSED_DOMAINS = [ + "alarm_control_panel", "button", "camera", "climate", "cover", "fan", + "group", "humidifier", "input_boolean", "input_button", "input_select", + "light", "lawn_mower", "lock", "media_player", "scene", "script", + "select", "switch", "vacuum", "valve", "water_heater", + ] + ga_const.SERVICE_REQUEST_SYNC = "request_sync" + + ga_http = register_mock("homeassistant.components.google_assistant.http") + + class _MockGoogleConfig: + def __init__(self, hass, config): + self.hass = hass + self._config = config + self._access_token = None + self.is_reporting_state = False + + @property + def should_report_state(self): + return bool(self._config.get("report_state", False)) + + async def async_initialize(self): + pass + + def async_enable_report_state(self): + self.is_reporting_state = True + + def async_disable_report_state(self): + self.is_reporting_state = False + + def async_get_agent_users(self): + return {} + + def async_schedule_google_sync_all(self): + pass + + async def async_sync_entities(self, agent_user_id: str): + return 200 + + async def async_sync_entities_all(self): + return 200 + + class _MockGoogleAssistantView(HomeAssistantView): + url = "/api/google_assistant" + name = "api:google_assistant" + + def __init__(self, config): + super().__init__() + self.config = config + + ga_http.GoogleConfig = _MockGoogleConfig + ga_http.GoogleAssistantView = _MockGoogleAssistantView + # loader loader = register_mock("homeassistant.loader") async def async_get_integration(hass, domain): integ = MagicMock() - integ.version = "1.0.3" + integ.version = "3.0.0" return integ loader.async_get_integration = async_get_integration @@ -184,6 +255,23 @@ async def async_get_integration(hass, domain): floor_reg_mod = register_mock("homeassistant.helpers.floor_registry") aiohttp_client_mod = register_mock("homeassistant.helpers.aiohttp_client") aiohttp_client_mod.async_get_clientsession = MagicMock() + storage_mod = register_mock("homeassistant.helpers.storage") + + class Store: + def __init__(self, hass, version, key, **kwargs): + self.hass = hass + self.version = version + self.key = key + self._data = None + + async def async_load(self): + return self._data + + async def async_save(self, data): + import copy + self._data = copy.deepcopy(data) + + storage_mod.Store = Store area_reg_mod.async_get = MagicMock(return_value=MagicMock(async_list_areas=MagicMock(return_value=[]))) dev_reg_mod.async_get = MagicMock(return_value=MagicMock(devices={})) @@ -195,6 +283,7 @@ async def async_get_integration(hass, domain): helpers.entity_registry = ent_reg_mod helpers.floor_registry = floor_reg_mod helpers.aiohttp_client = aiohttp_client_mod + helpers.storage = storage_mod # --------------------------------------------------------------------------- diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..e44dd28 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,134 @@ +import json +from unittest.mock import MagicMock + +import pytest + +from custom_components.google_assistant_entity_console.store import ( + ConsoleStore, + async_get_store, +) + + +@pytest.mark.asyncio +async def test_store_auto_migrates_legacy_files_and_yaml(tmp_path): + """Test that ConsoleStore imports legacy JSON files, secrets.yaml, SERVICE_ACCOUNT.json, and gaGen YAML on first load.""" + # 1. Create legacy blocklist and AI settings JSON + (tmp_path / "google_assistant_entity_console_blocklist.json").write_text( + json.dumps([r"^sensor\.ignored_.*"]), encoding="utf-8" + ) + (tmp_path / "google_assistant_entity_console_ai_settings.json").write_text( + json.dumps({"ai_source": "home_assistant", "ha_agent_id": "conversation.gemini"}), + encoding="utf-8", + ) + + # 2. Create secrets.yaml and SERVICE_ACCOUNT.json + (tmp_path / "secrets.yaml").write_text( + "googleassistant_projectName: my-home-project-123\ngoogle_device_pin: '4321'\n", + encoding="utf-8", + ) + service_account_data = { + "type": "service_account", + "project_id": "my-home-project-123", + "client_email": "homegraph@my-home-project-123.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC\n-----END PRIVATE KEY-----\n", + } + (tmp_path / "SERVICE_ACCOUNT.json").write_text( + json.dumps(service_account_data), encoding="utf-8" + ) + + # 3. Create gaGen_101726.yaml and configuration.yaml + (tmp_path / "gaGen_101726.yaml").write_text( + "project_id: !secret googleassistant_projectName\n" + "service_account: !include SERVICE_ACCOUNT.json\n" + "report_state: true\n" + "secure_devices_pin: !secret google_device_pin\n" + "expose_by_default: false\n" + "entity_config:\n" + " light.kitchen:\n" + " expose: true\n" + " name: Kitchen Light\n" + " aliases:\n" + " - Kitchen Lamp\n" + " room: Kitchen\n", + encoding="utf-8", + ) + (tmp_path / "configuration.yaml").write_text( + "homeassistant:\n name: Home\ngoogle_assistant: !include gaGen_101726.yaml\n", + encoding="utf-8", + ) + + hass = MagicMock() + hass.data = {} + hass.config.path.side_effect = lambda *parts: str(tmp_path.joinpath(*parts)) + + async def async_add_executor_job(func, *args): + return func(*args) + + hass.async_add_executor_job = async_add_executor_job + + store = await async_get_store(hass) + + assert store.blocklist == [r"^sensor\.ignored_.*"] + assert store.ai_settings["ha_agent_id"] == "conversation.gemini" + assert store.google_settings["project_id"] == "my-home-project-123" + assert store.google_settings["secure_devices_pin"] == "4321" + assert store.google_settings["report_state"] is True + assert ( + store.google_settings["service_account"]["client_email"] + == "homegraph@my-home-project-123.iam.gserviceaccount.com" + ) + assert "light.kitchen" in store.synced_entities + assert store.synced_entities["light.kitchen"]["name"] == "Kitchen Light" + assert await store.async_has_legacy_yaml() is True + + # Test commenting out google_assistant in configuration.yaml + mig_res = await store.async_migrate_and_disable_yaml() + assert mig_res["commented_out"] is True + assert await store.async_has_legacy_yaml() is False + updated_conf = (tmp_path / "configuration.yaml").read_text(encoding="utf-8") + assert "# google_assistant: !include gaGen_101726.yaml" in updated_conf + + +@pytest.mark.asyncio +async def test_store_update_google_settings_and_validation(tmp_path): + """Test updating Google settings via ConsoleStore and validating service account JSON.""" + hass = MagicMock() + hass.data = {} + hass.config.path.side_effect = lambda *parts: str(tmp_path.joinpath(*parts)) + + async def async_add_executor_job(func, *args): + return func(*args) + + hass.async_add_executor_job = async_add_executor_job + + store = ConsoleStore(hass) + await store.async_load() + + await store.async_update_google_settings( + { + "project_id": "new-proj-999", + "report_state": False, + "secure_devices_pin": "8888", + "auto_sync": True, + "service_account": json.dumps( + { + "client_email": "test@new-proj-999.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + } + ), + } + ) + + assert store.google_settings["project_id"] == "new-proj-999" + assert store.google_settings["report_state"] is False + assert store.google_settings["secure_devices_pin"] == "8888" + assert store.google_settings["auto_sync"] is True + assert ( + store.google_settings["service_account"]["client_email"] + == "test@new-proj-999.iam.gserviceaccount.com" + ) + + with pytest.raises(ValueError, match="client_email"): + await store.async_update_google_settings( + {"service_account": {"invalid": "dict"}} + ) From 00d1b11254db7e60ffb83e805222f1c07f0715ed Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sat, 17 Oct 2026 15:20:58 -0500 Subject: [PATCH 3/8] feat(runtime): add zero-reboot GoogleAssistantRuntimeManager for live .storage sync --- .../runtime.py | 323 ++++++++++++++++++ tests/test_runtime.py | 119 +++++++ 2 files changed, 442 insertions(+) create mode 100644 custom_components/google_assistant_entity_console/runtime.py create mode 100644 tests/test_runtime.py diff --git a/custom_components/google_assistant_entity_console/runtime.py b/custom_components/google_assistant_entity_console/runtime.py new file mode 100644 index 0000000..35bb390 --- /dev/null +++ b/custom_components/google_assistant_entity_console/runtime.py @@ -0,0 +1,323 @@ +"""Zero-reboot runtime manager bridging .storage with HA Core's GoogleConfig.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.google_assistant.const import ( + CONF_ENTITY_CONFIG, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + CONF_PROJECT_ID, + CONF_REPORT_STATE, + CONF_SECURE_DEVICES_PIN, + CONF_SERVICE_ACCOUNT, + DATA_CONFIG, + DEFAULT_EXPOSED_DOMAINS, + DOMAIN as GA_DOMAIN, + SERVICE_REQUEST_SYNC, +) +from homeassistant.components.google_assistant.http import ( + GoogleAssistantView, + GoogleConfig, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall + +from .const import DATA_RUNTIME, DOMAIN +from .store import ConsoleStore, async_get_store + +_LOGGER = logging.getLogger(__name__) +_VIEW_REGISTERED_KEY = f"{DOMAIN}_ga_view_registered" + + +def _has_valid_service_account(sa: Any) -> bool: + return bool( + isinstance(sa, dict) + and sa.get("client_email") + and sa.get("private_key") + ) + + +class GoogleAssistantRuntimeManager: + """Manage live GoogleConfig state and HomeGraph syncing without HA restarts.""" + + def __init__(self, hass: HomeAssistant, store: ConsoleStore) -> None: + self.hass = hass + self.store = store + self.google_config: GoogleConfig | None = None + self.mode: str = "unconfigured" + self._view: GoogleAssistantView | None = None + + def _build_full_config_dict( + self, entity_config: dict[str, dict[str, Any]] + ) -> dict[str, Any]: + gs = self.store.google_settings + cfg: dict[str, Any] = { + CONF_PROJECT_ID: gs.get("project_id", ""), + CONF_EXPOSE_BY_DEFAULT: False, + CONF_EXPOSED_DOMAINS: list(DEFAULT_EXPOSED_DOMAINS), + CONF_ENTITY_CONFIG: dict(entity_config), + CONF_REPORT_STATE: bool(gs.get("report_state", True)), + } + if gs.get("secure_devices_pin"): + cfg[CONF_SECURE_DEVICES_PIN] = str(gs["secure_devices_pin"]) + if _has_valid_service_account(gs.get("service_account")): + cfg[CONF_SERVICE_ACCOUNT] = dict(gs["service_account"]) + return cfg + + async def async_initialize( + self, entity_config: dict[str, dict[str, Any]] + ) -> None: + """Attach to existing YAML GoogleConfig or bootstrap standalone .storage GoogleConfig.""" + ga_entries = ( + self.hass.config_entries.async_entries(GA_DOMAIN) + if hasattr(self.hass, "config_entries") + and hasattr(self.hass.config_entries, "async_entries") + else [] + ) + if not isinstance(ga_entries, list): + ga_entries = [] + + for entry in ga_entries: + if ( + getattr(entry, "state", None) == ConfigEntryState.LOADED + and getattr(entry, "runtime_data", None) is not None + ): + self.google_config = entry.runtime_data + self.mode = "yaml_attached" + await self.async_refresh_memory_config(entity_config) + _LOGGER.info( + "Attached to active google_assistant config entry in memory (%d exposed entities)", + len(entity_config), + ) + return + + # Clean up any broken/unloaded SOURCE_IMPORT entries left over after removing YAML + for entry in list(ga_entries): + if ( + getattr(entry, "source", None) == SOURCE_IMPORT + and getattr(entry, "state", None) != ConfigEntryState.LOADED + ): + try: + await self.hass.config_entries.async_remove(entry.entry_id) + except Exception as err: + _LOGGER.debug( + "Could not remove stale google_assistant import entry: %s", err + ) + + project_id = self.store.google_settings.get("project_id", "").strip() + if not project_id: + self.mode = "unconfigured" + return + + config = self._build_full_config_dict(entity_config) + if not isinstance(getattr(self.hass, "data", None), dict): + self.hass.data = {} + self.hass.data[DATA_CONFIG] = config + + if self.google_config is None: + self.google_config = GoogleConfig(self.hass, config) + await self.google_config.async_initialize() + else: + self.google_config._config = config + + # Register /api/google_assistant HTTP endpoint once + existing_view = self.hass.data.get(_VIEW_REGISTERED_KEY) + if existing_view is None: + view = GoogleAssistantView(self.google_config) + self.hass.http.register_view(view) + self.hass.data[_VIEW_REGISTERED_KEY] = view + self._view = view + else: + existing_view.config = self.google_config + self._view = existing_view + + if ( + self.google_config.should_report_state + and _has_valid_service_account(config.get(CONF_SERVICE_ACCOUNT)) + and not self.google_config.is_reporting_state + ): + self.google_config.async_enable_report_state() + + if hasattr(self.hass, "services") and not self.hass.services.has_service( + GA_DOMAIN, SERVICE_REQUEST_SYNC + ): + async def _handle_request_sync(call: ServiceCall) -> None: + if self.google_config is None: + return + agent_user_id = call.data.get("agent_user_id") or call.context.user_id + if agent_user_id: + await self.google_config.async_sync_entities(agent_user_id) + else: + await self.google_config.async_sync_entities_all() + + self.hass.services.async_register( + GA_DOMAIN, SERVICE_REQUEST_SYNC, _handle_request_sync + ) + + self.mode = "storage" + _LOGGER.info( + "Initialized standalone .storage GoogleConfig for project '%s' (%d exposed entities)", + project_id, + len(entity_config), + ) + + async def async_switch_to_storage_mode( + self, entity_config: dict[str, dict[str, Any]] + ) -> None: + """Detach from legacy SOURCE_IMPORT entry if present and switch to standalone .storage mode.""" + ga_entries = ( + self.hass.config_entries.async_entries(GA_DOMAIN) + if hasattr(self.hass, "config_entries") + and hasattr(self.hass.config_entries, "async_entries") + else [] + ) + if isinstance(ga_entries, list): + for entry in list(ga_entries): + if getattr(entry, "source", None) == SOURCE_IMPORT: + if getattr(entry, "runtime_data", None) is not None: + self.google_config = entry.runtime_data + try: + await self.hass.config_entries.async_remove(entry.entry_id) + except Exception as err: + _LOGGER.debug( + "Note while removing legacy import entry: %s", err + ) + if self.google_config is None and self.store.google_settings.get("project_id"): + await self.async_initialize(entity_config) + else: + self.mode = "storage" + await self.async_refresh_memory_config(entity_config) + + async def async_refresh_memory_config( + self, + entity_config: dict[str, dict[str, Any]], + schedule_auto_sync: bool = False, + ) -> None: + """Mutate GoogleConfig._config in memory so changes take effect immediately.""" + if self.google_config is None: + if self.store.google_settings.get("project_id"): + await self.async_initialize(entity_config) + return + + cfg = self.google_config._config + cfg[CONF_ENTITY_CONFIG] = dict(entity_config) + + gs = self.store.google_settings + if gs.get("project_id"): + cfg[CONF_PROJECT_ID] = gs["project_id"] + if gs.get("secure_devices_pin"): + cfg[CONF_SECURE_DEVICES_PIN] = str(gs["secure_devices_pin"]) + elif CONF_SECURE_DEVICES_PIN in cfg and not gs.get("secure_devices_pin"): + cfg.pop(CONF_SECURE_DEVICES_PIN, None) + + if _has_valid_service_account(gs.get("service_account")): + if cfg.get(CONF_SERVICE_ACCOUNT) != gs["service_account"]: + cfg[CONF_SERVICE_ACCOUNT] = dict(gs["service_account"]) + self.google_config._access_token = None + + cfg[CONF_REPORT_STATE] = bool(gs.get("report_state", True)) + if cfg[CONF_REPORT_STATE] and _has_valid_service_account( + cfg.get(CONF_SERVICE_ACCOUNT) + ): + if not self.google_config.is_reporting_state: + self.google_config.async_enable_report_state() + else: + if self.google_config.is_reporting_state: + self.google_config.async_disable_report_state() + + if ( + schedule_auto_sync + and gs.get("auto_sync", False) + and _has_valid_service_account(cfg.get(CONF_SERVICE_ACCOUNT)) + and self.google_config.async_get_agent_users() + ): + self.google_config.async_schedule_google_sync_all() + + async def async_sync_to_google( + self, entity_config: dict[str, dict[str, Any]] + ) -> dict[str, Any]: + """Update in-memory GoogleConfig and trigger live HomeGraph Request Sync.""" + await self.async_refresh_memory_config(entity_config, schedule_auto_sync=False) + + if self.google_config is None: + await self.store.async_set_synced_entities(entity_config) + return { + "success": True, + "status_code": 204, + "exposed_count": len(entity_config), + "agent_users_count": 0, + "mode": self.mode, + "synced_offline": True, + "message": "Saved in .storage. Configure Google Project ID & Service Account in Settings to enable cloud sync.", + } + + agent_users = list(self.google_config.async_get_agent_users()) + has_sa = _has_valid_service_account( + self.google_config._config.get(CONF_SERVICE_ACCOUNT) + ) + + if not agent_users or not has_sa: + await self.store.async_set_synced_entities(entity_config) + return { + "success": True, + "status_code": 204, + "exposed_count": len(entity_config), + "agent_users_count": len(agent_users), + "mode": self.mode, + "synced_offline": True, + "message": ( + "Updated live in memory & .storage. Say 'Hey Google, sync my devices' " + "to link/sync with Google Home." + ), + } + + status_code = await self.google_config.async_sync_entities_all() + success = status_code in (200, 204) + if success: + await self.store.async_set_synced_entities(entity_config) + + return { + "success": success, + "status_code": int(status_code), + "exposed_count": len(entity_config), + "agent_users_count": len(agent_users), + "mode": self.mode, + "synced_offline": False, + } + + def get_status_summary(self) -> dict[str, Any]: + """Return current runtime status for UI display.""" + agent_users = ( + list(self.google_config.async_get_agent_users()) + if self.google_config is not None + else [] + ) + return { + "mode": self.mode, + "project_id": self.store.google_settings.get("project_id", ""), + "has_service_account": _has_valid_service_account( + self.store.google_settings.get("service_account") + ), + "is_reporting_state": bool( + self.google_config and self.google_config.is_reporting_state + ), + "agent_users_count": len(agent_users), + } + + +async def async_get_runtime( + hass: HomeAssistant, +) -> GoogleAssistantRuntimeManager: + """Get or create the singleton GoogleAssistantRuntimeManager.""" + if not isinstance(getattr(hass, "data", None), dict): + hass.data = {} + domain_data = hass.data.setdefault(DOMAIN, {}) + runtime: GoogleAssistantRuntimeManager | None = domain_data.get(DATA_RUNTIME) + if runtime is None: + store = await async_get_store(hass) + runtime = GoogleAssistantRuntimeManager(hass, store) + domain_data[DATA_RUNTIME] = runtime + return runtime diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..a9f6831 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,119 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.config_entries import ConfigEntryState + +from custom_components.google_assistant_entity_console.runtime import ( + GoogleAssistantRuntimeManager, + async_get_runtime, +) + + +@pytest.mark.asyncio +async def test_runtime_attaches_to_existing_yaml_google_config(): + """Verify runtime attaches to an active google_assistant ConfigEntry and mutates _config in place.""" + hass = MagicMock() + hass.data = {} + mock_google_config = MagicMock() + mock_google_config._config = {"project_id": "yaml-proj", "entity_config": {}} + mock_google_config.is_reporting_state = True + mock_google_config.async_get_agent_users.return_value = {"agent-1": {}} + mock_google_config.async_sync_entities_all = AsyncMock(return_value=200) + mock_google_config.async_schedule_google_sync_all = MagicMock() + + mock_entry = MagicMock() + mock_entry.state = ConfigEntryState.LOADED + mock_entry.runtime_data = mock_google_config + hass.config_entries.async_entries.return_value = [mock_entry] + + mock_store = MagicMock() + mock_store.google_settings = { + "project_id": "yaml-proj", + "service_account": {"client_email": "a@b.c", "private_key": "key"}, + "report_state": True, + "secure_devices_pin": "1234", + "auto_sync": True, + } + mock_store.async_set_synced_entities = AsyncMock() + + runtime = GoogleAssistantRuntimeManager(hass, mock_store) + initial_entities = {"light.office": {"expose": True, "name": "Office Light"}} + await runtime.async_initialize(initial_entities) + + assert runtime.mode == "yaml_attached" + assert mock_google_config._config["entity_config"] == initial_entities + + # Mutate in-memory config with auto_sync enabled + updated_entities = { + "light.office": {"expose": True, "name": "Office Main Light"}, + "switch.fan": {"expose": True, "name": "Office Fan"}, + } + await runtime.async_refresh_memory_config( + updated_entities, schedule_auto_sync=True + ) + assert mock_google_config._config["entity_config"] == updated_entities + mock_google_config.async_schedule_google_sync_all.assert_called_once() + + # Trigger immediate sync to Google + result = await runtime.async_sync_to_google(updated_entities) + assert result["success"] is True + assert result["status_code"] == 200 + assert result["exposed_count"] == 2 + mock_google_config.async_sync_entities_all.assert_awaited_once() + mock_store.async_set_synced_entities.assert_awaited_once_with(updated_entities) + + +@pytest.mark.asyncio +async def test_runtime_bootstraps_standalone_storage_mode(): + """Verify runtime initializes GoogleConfig and GoogleAssistantView directly from .storage when no YAML entry is loaded.""" + hass = MagicMock() + hass.data = {} + hass.config_entries.async_entries.return_value = [] + hass.services.has_service.return_value = False + + mock_store = MagicMock() + mock_store.google_settings = { + "project_id": "storage-proj-1", + "service_account": { + "client_email": "sa@proj.iam.gserviceaccount.com", + "private_key": "pem", + }, + "report_state": True, + "secure_devices_pin": "9999", + "auto_sync": False, + } + mock_store.async_set_synced_entities = AsyncMock() + + with ( + patch( + "custom_components.google_assistant_entity_console.runtime.GoogleConfig" + ) as mock_gc_cls, + patch( + "custom_components.google_assistant_entity_console.runtime.GoogleAssistantView" + ) as mock_view_cls, + ): + mock_gc_instance = MagicMock() + mock_gc_instance._config = {} + mock_gc_instance.async_initialize = AsyncMock() + mock_gc_instance.should_report_state = True + mock_gc_instance.is_reporting_state = False + mock_gc_instance.async_get_agent_users.return_value = {} + mock_gc_cls.return_value = mock_gc_instance + + runtime = GoogleAssistantRuntimeManager(hass, mock_store) + await runtime.async_initialize( + {"light.kitchen": {"expose": True, "name": "Kitchen"}} + ) + + assert runtime.mode == "storage" + mock_gc_instance.async_initialize.assert_awaited_once() + hass.http.register_view.assert_called_once_with(mock_view_cls.return_value) + mock_gc_instance.async_enable_report_state.assert_called_once() + + # Verify offline sync when no agent_users linked yet still saves synced_entities + sync_res = await runtime.async_sync_to_google( + {"light.kitchen": {"expose": True, "name": "Kitchen"}} + ) + assert sync_res["success"] is True + assert sync_res["synced_offline"] is True + mock_store.async_set_synced_entities.assert_awaited_once() From 12be6d057f91360cac11dc3bbef51750a120ed96 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sat, 17 Oct 2026 15:26:16 -0500 Subject: [PATCH 4/8] feat(views): wire .storage store, live sync endpoint, and Google credentials API --- .../__init__.py | 54 +++-- .../google_assistant_entity_console/views.py | 204 +++++++++++++++++- tests/test_config_flow.py | 16 +- tests/test_views.py | 157 ++++++++++++++ 4 files changed, 406 insertions(+), 25 deletions(-) diff --git a/custom_components/google_assistant_entity_console/__init__.py b/custom_components/google_assistant_entity_console/__init__.py index 4bf5f9d..227675b 100644 --- a/custom_components/google_assistant_entity_console/__init__.py +++ b/custom_components/google_assistant_entity_console/__init__.py @@ -9,6 +9,8 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN +from .runtime import async_get_runtime +from .store import async_get_store from .views import ( AIGenerateNicknamesView, AIGenerateSingleEntityNicknameView, @@ -19,45 +21,68 @@ BlocklistAddView, BlocklistView, EntitiesView, + GoogleSettingsView, + MigrateYamlView, RebuildView, RestartView, + SyncView, UpdateEntityView, + async_build_entity_config_map, ) _LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Google Assistant Entity Console component (legacy YAML fallback).""" return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Google Assistant Entity Console from a config entry.""" _LOGGER.info("Setting up Google Assistant Entity Console from config entry") # Read version natively via Home Assistant loader for cache busting from homeassistant.loader import async_get_integration + try: integration = await async_get_integration(hass, DOMAIN) version = integration.version except Exception as err: _LOGGER.error("Failed to read version from integration loader: %s", err) - version = "1.0.3" - - # 1. Register static files directory - static_dir = hass.config.path("custom_components/google_assistant_entity_console/static") - await hass.http.async_register_static_paths([ - StaticPathConfig( - "/google_assistant_entity_console/static", - static_dir, - True, - ) - ]) - - # 2. Register API views + version = "3.0.0" + + # 1. Initialize .storage store and zero-reboot GoogleConfig runtime manager + try: + await async_get_store(hass) + runtime = await async_get_runtime(hass) + entity_config = await async_build_entity_config_map(hass) + await runtime.async_initialize(entity_config) + except Exception as err: + _LOGGER.warning("Deferred runtime initialization: %s", err) + + # 2. Register static files directory + static_dir = hass.config.path( + "custom_components/google_assistant_entity_console/static" + ) + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + "/google_assistant_entity_console/static", + static_dir, + True, + ) + ] + ) + + # 3. Register API views hass.http.register_view(EntitiesView()) hass.http.register_view(UpdateEntityView()) + hass.http.register_view(SyncView()) hass.http.register_view(RebuildView()) hass.http.register_view(RestartView()) + hass.http.register_view(GoogleSettingsView()) + hass.http.register_view(MigrateYamlView()) hass.http.register_view(BlocklistView()) hass.http.register_view(BlocklistAddView()) hass.http.register_view(AISettingsView()) @@ -67,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.http.register_view(AISuggestExposureView()) hass.http.register_view(AIGenerateSingleEntityNicknameView()) - # 3. Register Sidebar Panel + # 4. Register Sidebar Panel async_remove_panel(hass, DOMAIN, warn_if_unknown=False) async_register_built_in_panel( @@ -88,6 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.info("Unloading Google Assistant Entity Console config entry") diff --git a/custom_components/google_assistant_entity_console/views.py b/custom_components/google_assistant_entity_console/views.py index 87ed2d3..0c0d3b4 100644 --- a/custom_components/google_assistant_entity_console/views.py +++ b/custom_components/google_assistant_entity_console/views.py @@ -18,7 +18,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util -from .const import DOMAIN +from .const import DATA_RUNTIME, DATA_STORE, DOMAIN +from .runtime import async_get_runtime +from .store import async_get_store _LOGGER = logging.getLogger(__name__) @@ -129,11 +131,19 @@ def save_blocklist(hass: HomeAssistant, blocklist: list) -> None: async def async_load_blocklist(hass: HomeAssistant) -> list: - return await hass.async_add_executor_job(load_blocklist, hass) + data = await hass.async_add_executor_job(load_blocklist, hass) + if not data and isinstance(getattr(hass, "data", None), dict) and DATA_STORE in hass.data.get(DOMAIN, {}): + store = await async_get_store(hass) + if store.blocklist: + return list(store.blocklist) + return data async def async_save_blocklist(hass: HomeAssistant, blocklist: list) -> None: await hass.async_add_executor_job(save_blocklist, hass, blocklist) + if isinstance(getattr(hass, "data", None), dict) and DATA_STORE in hass.data.get(DOMAIN, {}): + store = await async_get_store(hass) + await store.async_set_blocklist(blocklist) def _read_file_content(path): @@ -221,9 +231,16 @@ async def async_fetch_entities_data(hass: HomeAssistant): # Build floor name mapping floor_map = {floor.floor_id: floor.name for floor in floor_reg.async_list_floors()} - # Load currently exposed YAML config to compare + # Load currently exposed YAML config or .storage synced_entities to compare yaml_filename = await get_current_yaml_filename(hass) yaml_exposed = await load_yaml_exposed_entities(hass, yaml_filename) + if ( + isinstance(getattr(hass, "data", None), dict) + and DATA_STORE in hass.data.get(DOMAIN, {}) + ): + store = await async_get_store(hass) + if store.synced_entities: + yaml_exposed = store.synced_entities # Build device mappings device_area_map = {} @@ -313,7 +330,7 @@ async def async_fetch_entities_data(hass: HomeAssistant): if entry.options and "conversation" in entry.options: should_expose = entry.options["conversation"].get("should_expose", False) - # Compare with what is in the yaml file + # Compare with what is in the yaml file / synced_entities store yaml_exp = entity_id in yaml_exposed # Clean/filter aliases (strictly allow only valid string values) @@ -351,6 +368,27 @@ async def async_fetch_entities_data(hass: HomeAssistant): return active_entities +async def async_build_entity_config_map( + hass: HomeAssistant, +) -> dict[str, dict[str, Any]]: + """Build the entity_config map expected by HA Core's GoogleConfig from active entities.""" + entities = await async_fetch_entities_data(hass) + entity_config: dict[str, dict[str, Any]] = {} + for ent in entities: + if not ent.get("should_expose"): + continue + cfg: dict[str, Any] = { + "expose": True, + "name": ent["display_name"], + } + if ent.get("aliases"): + cfg["aliases"] = list(ent["aliases"]) + if ent.get("area") and ent["area"] != "TBA": + cfg["room"] = ent["area"] + entity_config[ent["entity_id"]] = cfg + return entity_config + + class EntitiesView(HomeAssistantView): url = "/api/google_assistant_entity_console/entities" name = "api:google_assistant_entity_console:entities" @@ -369,10 +407,29 @@ async def get(self, request: web.Request) -> web.Response: except Exception: version = "Unknown" + storage_mode = "storage" + project_id = "" + has_legacy_yaml = False + auto_sync = False + try: + if isinstance(getattr(hass, "data", None), dict) and DOMAIN in hass.data: + store = await async_get_store(hass) + runtime = await async_get_runtime(hass) + storage_mode = runtime.mode + project_id = store.google_settings.get("project_id", "") + auto_sync = bool(store.google_settings.get("auto_sync", False)) + has_legacy_yaml = await store.async_has_legacy_yaml() + except Exception: + pass + return self.json({ "entities": entities, "yaml_filename": yaml_filename, - "version": version + "version": version, + "storage_mode": storage_mode, + "project_id": project_id, + "has_legacy_yaml": has_legacy_yaml, + "auto_sync": auto_sync, }) except Exception as err: _LOGGER.exception("Failed to fetch entities registry") @@ -419,6 +476,20 @@ async def post(self, request: web.Request) -> web.Response: {"should_expose": bool(should_expose)} ) + # Immediately refresh in-memory GoogleConfig if runtime is active + if ( + isinstance(getattr(hass, "data", None), dict) + and DATA_RUNTIME in hass.data.get(DOMAIN, {}) + ): + try: + runtime = await async_get_runtime(hass) + entity_config = await async_build_entity_config_map(hass) + await runtime.async_refresh_memory_config( + entity_config, schedule_auto_sync=True + ) + except Exception as sync_err: + _LOGGER.debug("Live memory refresh skipped: %s", sync_err) + return self.json({"success": True}) except Exception as err: _LOGGER.exception("Failed to update entity settings") @@ -552,6 +623,119 @@ class CustomLoader(yaml.SafeLoader): return self.json({"error": str(err)}, status_code=500) +class SyncView(HomeAssistantView): + url = "/api/google_assistant_entity_console/sync" + name = "api:google_assistant_entity_console:sync" + + async def post(self, request: web.Request) -> web.Response: + hass = request.app["hass"] + try: + runtime = await async_get_runtime(hass) + entity_config = await async_build_entity_config_map(hass) + result = await runtime.async_sync_to_google(entity_config) + return self.json(result) + except Exception as err: + _LOGGER.exception("Failed to sync entities to Google") + return self.json({"error": str(err)}, status_code=500) + + +def _serialize_google_settings(store_settings: dict[str, Any], status_summary: dict[str, Any], has_legacy_yaml: bool) -> dict[str, Any]: + sa = store_settings.get("service_account") or {} + client_email = sa.get("client_email", "") if isinstance(sa, dict) else "" + has_sa = bool(isinstance(sa, dict) and sa.get("client_email") and sa.get("private_key")) + return { + "project_id": store_settings.get("project_id", ""), + "report_state": bool(store_settings.get("report_state", True)), + "secure_devices_pin": store_settings.get("secure_devices_pin", ""), + "auto_sync": bool(store_settings.get("auto_sync", False)), + "client_email": client_email, + "has_service_account": has_sa, + "mode": status_summary.get("mode", "storage"), + "is_reporting_state": status_summary.get("is_reporting_state", False), + "agent_users_count": status_summary.get("agent_users_count", 0), + "has_legacy_yaml": has_legacy_yaml, + } + + +class GoogleSettingsView(HomeAssistantView): + url = "/api/google_assistant_entity_console/google_settings" + name = "api:google_assistant_entity_console:google_settings" + + async def get(self, request: web.Request) -> web.Response: + hass = request.app["hass"] + try: + store = await async_get_store(hass) + runtime = await async_get_runtime(hass) + has_legacy_yaml = await store.async_has_legacy_yaml() + payload = _serialize_google_settings( + store.google_settings, + runtime.get_status_summary(), + has_legacy_yaml, + ) + return self.json({"google_settings": payload}) + except Exception as err: + _LOGGER.exception("Failed to load Google Assistant settings") + return self.json({"error": str(err)}, status_code=500) + + async def post(self, request: web.Request) -> web.Response: + hass = request.app["hass"] + try: + body = await request.json() + if not isinstance(body, dict): + return self.json({"error": "Invalid JSON body"}, status_code=400) + updates = body.get("google_settings", body) + if not isinstance(updates, dict): + return self.json({"error": "google_settings must be an object"}, status_code=400) + + store = await async_get_store(hass) + try: + await store.async_update_google_settings(updates) + except ValueError as val_err: + return self.json({"error": str(val_err)}, status_code=400) + + runtime = await async_get_runtime(hass) + entity_config = await async_build_entity_config_map(hass) + await runtime.async_refresh_memory_config(entity_config) + has_legacy_yaml = await store.async_has_legacy_yaml() + payload = _serialize_google_settings( + store.google_settings, + runtime.get_status_summary(), + has_legacy_yaml, + ) + return self.json({"success": True, "google_settings": payload}) + except Exception as err: + _LOGGER.exception("Failed to save Google Assistant settings") + return self.json({"error": str(err)}, status_code=500) + + +class MigrateYamlView(HomeAssistantView): + url = "/api/google_assistant_entity_console/migrate_yaml" + name = "api:google_assistant_entity_console:migrate_yaml" + + async def post(self, request: web.Request) -> web.Response: + hass = request.app["hass"] + try: + store = await async_get_store(hass) + mig_res = await store.async_migrate_and_disable_yaml() + runtime = await async_get_runtime(hass) + entity_config = await async_build_entity_config_map(hass) + await runtime.async_switch_to_storage_mode(entity_config) + has_legacy_yaml = await store.async_has_legacy_yaml() + return self.json({ + "success": True, + "commented_out": mig_res["commented_out"], + "project_id": mig_res["project_id"], + "google_settings": _serialize_google_settings( + store.google_settings, + runtime.get_status_summary(), + has_legacy_yaml, + ), + }) + except Exception as err: + _LOGGER.exception("Failed to migrate YAML to .storage") + return self.json({"error": str(err)}, status_code=500) + + class RestartView(HomeAssistantView): url = "/api/google_assistant_entity_console/restart" name = "api:google_assistant_entity_console:restart" @@ -648,11 +832,19 @@ def save_ai_settings(hass: HomeAssistant, settings: dict) -> None: async def async_load_ai_settings(hass: HomeAssistant) -> dict: - return await hass.async_add_executor_job(load_ai_settings, hass) + data = await hass.async_add_executor_job(load_ai_settings, hass) + if not data and isinstance(getattr(hass, "data", None), dict) and DATA_STORE in hass.data.get(DOMAIN, {}): + store = await async_get_store(hass) + if store.ai_settings: + return dict(store.ai_settings) + return data async def async_save_ai_settings(hass: HomeAssistant, settings: dict) -> None: await hass.async_add_executor_job(save_ai_settings, hass, settings) + if isinstance(getattr(hass, "data", None), dict) and DATA_STORE in hass.data.get(DOMAIN, {}): + store = await async_get_store(hass) + await store.async_set_ai_settings(settings) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0c641f7..af0b8cb 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -23,8 +23,11 @@ BlocklistAddView, BlocklistView, EntitiesView, + GoogleSettingsView, + MigrateYamlView, RebuildView, RestartView, + SyncView, UpdateEntityView, ) @@ -153,8 +156,8 @@ async def test_async_setup_entry_success(lifecycle_hass): ) assert static_path_config.cache_headers is True - # 2. Verify registration of all 12 API views in exact order - assert lifecycle_hass.http.register_view.call_count == 12 + # 2. Verify registration of all 15 API views in exact order + assert lifecycle_hass.http.register_view.call_count == 15 registered_classes = [ call.args[0].__class__ for call in lifecycle_hass.http.register_view.call_args_list @@ -162,8 +165,11 @@ async def test_async_setup_entry_success(lifecycle_hass): expected_classes = [ EntitiesView, UpdateEntityView, + SyncView, RebuildView, RestartView, + GoogleSettingsView, + MigrateYamlView, BlocklistView, BlocklistAddView, AISettingsView, @@ -198,7 +204,7 @@ async def test_async_setup_entry_success(lifecycle_hass): @pytest.mark.anyio async def test_async_setup_entry_version_fallback_on_error(lifecycle_hass): - """Test async_setup_entry falls back gracefully to version 1.0.3 when loader fails.""" + """Test async_setup_entry falls back gracefully to version 3.0.0 when loader fails.""" mock_entry = MagicMock() with patch( @@ -213,10 +219,10 @@ async def test_async_setup_entry_version_fallback_on_error(lifecycle_hass): assert result is True panel_kwargs = mock_register_panel.call_args.kwargs - assert panel_kwargs["config"]["version"] == "1.0.3" + assert panel_kwargs["config"]["version"] == "3.0.0" assert ( panel_kwargs["config"]["_panel_custom"]["js_url"] - == "/google_assistant_entity_console/static/panel.js?v=1.0.3" + == "/google_assistant_entity_console/static/panel.js?v=3.0.0" ) diff --git a/tests/test_views.py b/tests/test_views.py index 9fc4321..28b4037 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1550,3 +1550,160 @@ def __str__(self): # The COMPUTED_NAME alias should be filtered out, leaving only the string alias assert ent["aliases"] == ["kitchen_alias"] + +# =========================================================================== +# 11. .storage Live Sync, GoogleSettingsView, and MigrateYamlView +# =========================================================================== + +@pytest.mark.anyio +async def test_sync_view_post_triggers_live_homegraph_sync(mock_hass, tmp_path): + """Verifies SyncView triggers runtime.async_sync_to_google without generating YAML or restarting.""" + from custom_components.google_assistant_entity_console.views import SyncView + + mock_hass.config.path.side_effect = lambda *parts: str(tmp_path.joinpath(*parts)) + + sample_entities = [ + { + "entity_id": "light.kitchen", + "display_name": "Kitchen Light", + "aliases": ["Island Light"], + "area": "Kitchen", + "floor": "Main Floor", + "domain": "light", + "should_expose": True, + }, + { + "entity_id": "switch.unexposed", + "display_name": "Hidden Switch", + "aliases": [], + "area": "TBA", + "floor": "TBA", + "domain": "switch", + "should_expose": False, + }, + ] + + mock_runtime = MagicMock() + mock_runtime.async_sync_to_google = AsyncMock( + return_value={ + "success": True, + "status_code": 200, + "exposed_count": 1, + "agent_users_count": 1, + "mode": "storage", + "synced_offline": False, + } + ) + + view = SyncView() + request = MagicMock() + request.app = {"hass": mock_hass} + + with ( + patch( + "custom_components.google_assistant_entity_console.views.async_fetch_entities_data", + return_value=sample_entities, + ), + patch( + "custom_components.google_assistant_entity_console.views.async_get_runtime", + AsyncMock(return_value=mock_runtime), + ), + ): + resp = await view.post(request) + assert resp.status == 200 + data = json.loads(resp.body.decode()) + assert data["success"] is True + assert data["exposed_count"] == 1 + assert data["mode"] == "storage" + mock_runtime.async_sync_to_google.assert_awaited_once_with( + { + "light.kitchen": { + "expose": True, + "name": "Kitchen Light", + "aliases": ["Island Light"], + "room": "Kitchen", + } + } + ) + + +@pytest.mark.anyio +async def test_google_settings_view_get_and_post(mock_hass, tmp_path): + """Verifies GoogleSettingsView GET redacts private_key and POST updates .storage + runtime.""" + from custom_components.google_assistant_entity_console.views import ( + GoogleSettingsView, + ) + + mock_hass.config.path.side_effect = lambda *parts: str(tmp_path.joinpath(*parts)) + + view = GoogleSettingsView() + post_req = AsyncMock() + post_req.app = {"hass": mock_hass} + post_req.json.return_value = { + "project_id": "home-assistant-v3", + "report_state": True, + "secure_devices_pin": "2468", + "auto_sync": True, + "service_account": { + "client_email": "bot@home-assistant-v3.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----", + }, + } + + with patch( + "custom_components.google_assistant_entity_console.views.async_fetch_entities_data", + return_value=[], + ): + post_resp = await view.post(post_req) + assert post_resp.status == 200 + post_data = json.loads(post_resp.body.decode()) + assert post_data["success"] is True + assert post_data["google_settings"]["project_id"] == "home-assistant-v3" + assert ( + post_data["google_settings"]["client_email"] + == "bot@home-assistant-v3.iam.gserviceaccount.com" + ) + assert post_data["google_settings"]["has_service_account"] is True + assert "private_key" not in post_data["google_settings"] + + get_req = MagicMock() + get_req.app = {"hass": mock_hass} + get_resp = await view.get(get_req) + assert get_resp.status == 200 + get_data = json.loads(get_resp.body.decode()) + assert get_data["google_settings"]["project_id"] == "home-assistant-v3" + assert get_data["google_settings"]["secure_devices_pin"] == "2468" + assert get_data["google_settings"]["auto_sync"] is True + assert get_data["google_settings"]["has_service_account"] is True + assert "private_key" not in get_data["google_settings"] + + +@pytest.mark.anyio +async def test_migrate_yaml_view_post(mock_hass, tmp_path): + """Verifies MigrateYamlView comments out configuration.yaml and switches runtime to storage mode.""" + from custom_components.google_assistant_entity_console.views import MigrateYamlView + + (tmp_path / "gaGen_101726.yaml").write_text( + "project_id: migrated-proj\nreport_state: true\n", encoding="utf-8" + ) + (tmp_path / "configuration.yaml").write_text( + "google_assistant: !include gaGen_101726.yaml\n", encoding="utf-8" + ) + mock_hass.config.path.side_effect = lambda *parts: str(tmp_path.joinpath(*parts)) + + view = MigrateYamlView() + req = MagicMock() + req.app = {"hass": mock_hass} + + with patch( + "custom_components.google_assistant_entity_console.views.async_fetch_entities_data", + return_value=[], + ): + resp = await view.post(req) + assert resp.status == 200 + data = json.loads(resp.body.decode()) + assert data["success"] is True + assert data["commented_out"] is True + assert data["project_id"] == "migrated-proj" + + From 7289f80e8c31971e127bf74cada48f835cd4a9e9 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sat, 17 Oct 2026 15:32:29 -0500 Subject: [PATCH 5/8] feat(ui): replace YAML rebuild/restart flow with live .storage Sync to Google and credentials UI --- .../runtime.py | 14 +- .../static/app.js | 161 +++++++++++++++--- .../static/index.html | 139 ++++++++++----- .../google_assistant_entity_console/store.py | 12 +- tests/test_playwright_e2e.py | 142 +++++++++++++++ tests/test_runtime.py | 1 - 6 files changed, 390 insertions(+), 79 deletions(-) diff --git a/custom_components/google_assistant_entity_console/runtime.py b/custom_components/google_assistant_entity_console/runtime.py index 35bb390..abaa6e9 100644 --- a/custom_components/google_assistant_entity_console/runtime.py +++ b/custom_components/google_assistant_entity_console/runtime.py @@ -15,9 +15,11 @@ CONF_SERVICE_ACCOUNT, DATA_CONFIG, DEFAULT_EXPOSED_DOMAINS, - DOMAIN as GA_DOMAIN, SERVICE_REQUEST_SYNC, ) +from homeassistant.components.google_assistant.const import ( + DOMAIN as GA_DOMAIN, +) from homeassistant.components.google_assistant.http import ( GoogleAssistantView, GoogleConfig, @@ -213,10 +215,12 @@ async def async_refresh_memory_config( elif CONF_SECURE_DEVICES_PIN in cfg and not gs.get("secure_devices_pin"): cfg.pop(CONF_SECURE_DEVICES_PIN, None) - if _has_valid_service_account(gs.get("service_account")): - if cfg.get(CONF_SERVICE_ACCOUNT) != gs["service_account"]: - cfg[CONF_SERVICE_ACCOUNT] = dict(gs["service_account"]) - self.google_config._access_token = None + if ( + _has_valid_service_account(gs.get("service_account")) + and cfg.get(CONF_SERVICE_ACCOUNT) != gs["service_account"] + ): + cfg[CONF_SERVICE_ACCOUNT] = dict(gs["service_account"]) + self.google_config._access_token = None cfg[CONF_REPORT_STATE] = bool(gs.get("report_state", True)) if cfg[CONF_REPORT_STATE] and _has_valid_service_account( diff --git a/custom_components/google_assistant_entity_console/static/app.js b/custom_components/google_assistant_entity_console/static/app.js index 2489c82..3a84483 100644 --- a/custom_components/google_assistant_entity_console/static/app.js +++ b/custom_components/google_assistant_entity_console/static/app.js @@ -146,10 +146,20 @@ async function fetchEntities() { const data = await response.json(); entities = Array.isArray(data) ? data : (data.entities || []); - // Update Active YAML Config display name + // Update Sync Mode / Config display name const configFileEl = document.getElementById('configFile'); if (configFileEl) { - configFileEl.textContent = Array.isArray(data) ? 'gaGen_112225.yaml' : (data.yaml_filename || 'None (Not Configured)'); + if (Array.isArray(data)) { + configFileEl.textContent = '.storage Live'; + } else if (data.storage_mode === 'storage') { + configFileEl.textContent = data.project_id ? `.storage Live (${data.project_id})` : '.storage Live'; + } else if (data.storage_mode === 'yaml_attached') { + configFileEl.textContent = data.project_id ? `YAML Attached (${data.project_id})` : 'YAML Attached'; + } else if (data.yaml_filename && data.yaml_filename !== '.storage') { + configFileEl.textContent = data.yaml_filename; + } else { + configFileEl.textContent = '.storage Live'; + } } // Update app version label @@ -1417,47 +1427,47 @@ window.openQuickAliasModal = function(entityId, event) { // Open Edit Modal -// Trigger YAML generation & rebuild +// Trigger live zero-reboot sync to Google HomeGraph & .storage async function handleRebuild() { rebuildBtn.disabled = true; const icon = rebuildBtn.querySelector('i'); icon.className = 'fa-solid fa-spinner fa-spin'; try { - const result = await authFetch('/api/google_assistant_entity_console/rebuild', { + let result = await authFetch('/api/google_assistant_entity_console/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + if (result.status === 404) { + result = await authFetch('/api/google_assistant_entity_console/rebuild', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({}) + }); + } if (!result.ok) { const err = await result.json(); - throw new Error(err.error || 'Rebuild failed'); + throw new Error(err.error || 'Sync failed'); } const data = await result.json(); - showToast(`Configuration generated successfully. (${data.exposed_count} entities exposed)`, 'success'); - - const missingIncludeContainer = document.getElementById('missingIncludeContainer'); - const missingIncludeInput = document.getElementById('missingIncludeInput'); - if (missingIncludeContainer && missingIncludeInput) { - if (data.has_include_directive === false && data.required_include) { - missingIncludeInput.value = data.required_include; - missingIncludeContainer.style.display = 'block'; - } else { - missingIncludeContainer.style.display = 'none'; - } + await fetchEntities(); + if (data.message) { + showToast(data.message, 'success'); + } else { + showToast(`Synced ${data.exposed_count} entities to Google HomeGraph!`, 'success'); } - - // Open restart confirmation modal - restartModal.style.display = 'block'; } catch (error) { - showToast('Error rebuilding configs: ' + error.message, 'error'); + showToast('Error syncing to Google: ' + error.message, 'error'); } finally { rebuildBtn.disabled = false; - icon.className = 'fa-solid fa-file-code'; + icon.className = 'fa-solid fa-cloud-arrow-up'; } } @@ -1820,8 +1830,48 @@ window.addToBlocklistDirectly = async function(entityId, event) { } }; -// AI Features: Settings, Modal, and Assistant Logic +// AI & Google Assistant Features: Settings, Modal, and Assistant Logic let aiSettings = {}; +let googleSettings = {}; + +async function loadGoogleSettings() { + try { + const response = await authFetch('/api/google_assistant_entity_console/google_settings', { + headers: getAuthHeaders() + }); + if (!response.ok) return; + const data = await response.json(); + googleSettings = data.google_settings || {}; + + const projInput = document.getElementById('gaProjectIdInput'); + const pinInput = document.getElementById('gaPinInput'); + const reportStateToggle = document.getElementById('gaReportStateToggle'); + const autoSyncToggle = document.getElementById('gaAutoSyncToggle'); + const saStatus = document.getElementById('gaServiceAccountStatus'); + const saInput = document.getElementById('gaServiceAccountInput'); + const migrateBtn = document.getElementById('migrateYamlBtn'); + + if (projInput) projInput.value = googleSettings.project_id || ''; + if (pinInput) pinInput.value = googleSettings.secure_devices_pin || ''; + if (reportStateToggle) reportStateToggle.checked = googleSettings.report_state !== false; + if (autoSyncToggle) autoSyncToggle.checked = googleSettings.auto_sync !== false; + if (saInput) saInput.value = ''; + if (saStatus) { + if (googleSettings.has_service_account && googleSettings.client_email) { + saStatus.textContent = `Stored: ${googleSettings.client_email}`; + saStatus.style.color = 'var(--success)'; + } else { + saStatus.textContent = 'Not configured'; + saStatus.style.color = 'var(--on-surface-variant)'; + } + } + if (migrateBtn) { + migrateBtn.style.display = data.has_legacy_yaml ? 'inline-flex' : 'none'; + } + } catch (e) { + console.error('Failed to load Google settings', e); + } +} async function loadAiSettings() { try { @@ -1879,9 +1929,10 @@ function updateConnectionFieldsDisplay(source) { } } -// Bind AI Events +// Bind AI & Google Settings Events function setupAiEventListeners() { loadAiSettings(); + loadGoogleSettings(); const openAiSettingsBtn = document.getElementById('openAiSettingsBtn'); const aiSettingsModal = document.getElementById('aiSettingsModal'); @@ -1889,13 +1940,43 @@ function setupAiEventListeners() { const closeAiSettingsModalBtn = document.getElementById('closeAiSettingsModalBtn'); const saveAiSettingsBtn = document.getElementById('saveAiSettingsBtn'); const queryModelsBtn = document.getElementById('queryModelsBtn'); + const migrateYamlBtn = document.getElementById('migrateYamlBtn'); if (openAiSettingsBtn && aiSettingsModal) { openAiSettingsBtn.addEventListener('click', () => { + loadGoogleSettings(); + loadAiSettings(); aiSettingsModal.style.display = 'block'; switchSettingsTab('connection'); }); } + + if (migrateYamlBtn) { + migrateYamlBtn.addEventListener('click', async () => { + migrateYamlBtn.disabled = true; + try { + const response = await authFetch('/api/google_assistant_entity_console/migrate_yaml', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({}) + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Migration failed'); + } + await loadGoogleSettings(); + await fetchEntities(); + showToast('Migrated Google Assistant credentials to .storage and disabled YAML block!', 'success'); + } catch (error) { + showToast('Error migrating YAML: ' + error.message, 'error'); + } finally { + migrateYamlBtn.disabled = false; + } + }); + } const closeSettings = () => { if (aiSettingsModal) aiSettingsModal.style.display = 'none'; @@ -1969,10 +2050,40 @@ function setupAiEventListeners() { const nicknamePrompt = document.getElementById('nicknamePrompt').value; const singleNicknamePrompt = document.getElementById('singleNicknamePrompt').value; const exposurePrompt = document.getElementById('exposurePrompt').value; + + const gaProjectIdEl = document.getElementById('gaProjectIdInput'); + const gaPinEl = document.getElementById('gaPinInput'); + const gaReportStateEl = document.getElementById('gaReportStateToggle'); + const gaAutoSyncEl = document.getElementById('gaAutoSyncToggle'); + const gaSaInputEl = document.getElementById('gaServiceAccountInput'); saveAiSettingsBtn.disabled = true; saveAiSettingsBtn.textContent = 'Saving...'; try { + if (gaProjectIdEl) { + const gaPayload = { + project_id: gaProjectIdEl.value.trim(), + secure_devices_pin: gaPinEl ? gaPinEl.value.trim() : '', + report_state: gaReportStateEl ? gaReportStateEl.checked : true, + auto_sync: gaAutoSyncEl ? gaAutoSyncEl.checked : true, + }; + if (gaSaInputEl && gaSaInputEl.value.trim()) { + gaPayload.service_account = gaSaInputEl.value.trim(); + } + const gaResponse = await authFetch('/api/google_assistant_entity_console/google_settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify(gaPayload) + }); + if (!gaResponse.ok && gaResponse.status !== 404) { + const err = await gaResponse.json(); + throw new Error(err.error || 'Failed to save Google Assistant settings'); + } + } + const response = await authFetch('/api/google_assistant_entity_console/ai/settings', { method: 'POST', headers: { @@ -2006,7 +2117,9 @@ function setupAiEventListeners() { single_nickname_prompt: singleNicknamePrompt, exposure_prompt: exposurePrompt }; - showToast('AI Settings saved successfully', 'success'); + await loadGoogleSettings(); + await fetchEntities(); + showToast('Settings saved to .storage successfully', 'success'); closeSettings(); } catch (error) { showToast('Error saving settings: ' + error.message, 'error'); diff --git a/custom_components/google_assistant_entity_console/static/index.html b/custom_components/google_assistant_entity_console/static/index.html index 6103118..a97a7cc 100644 --- a/custom_components/google_assistant_entity_console/static/index.html +++ b/custom_components/google_assistant_entity_console/static/index.html @@ -38,16 +38,16 @@

Google Assistant

@@ -143,9 +143,9 @@

0

-
+
- Active YAML Config + Sync Mode

Loading...

@@ -322,66 +322,119 @@

Restart Home Assistant?

- +