diff --git a/README.md b/README.md index 7e61898..2a921b0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@
- A native Home Assistant custom component providing a sidebar dashboard to manage Google Assistant entity exposure, voice nicknames, regex blocklists, and YAML generation.
+ A native Home Assistant custom component providing a sidebar dashboard to manage Google Assistant entity exposure, voice nicknames, regex blocklists, and zero-reboot .storage live syncing.
@@ -21,19 +21,22 @@ ## Overview -Configuring entities exposed to Google Assistant in Home Assistant traditionally requires manual YAML configuration, tracking entity IDs, maintaining aliases, and reloading configurations. +Self-hosted Google Assistant in Home Assistant traditionally requires manual YAML configuration (`configuration.yaml`), tracking entity IDs, maintaining aliases, and restarting Home Assistant whenever exposed entities change. -**Google Assistant Entity Console** provides a native sidebar dashboard to manage Google Assistant entity configuration visually. It queries the Home Assistant registry (floors, areas, devices, entities), renders dual-layout entity trees, generates voice nicknames using local or cloud models or native Assist conversation agents, and writes structured YAML configuration files (`gaGen_YYMMDD.yaml`) with support for zero-downtime reloads. +**Google Assistant Entity Console v3.0.0** eliminates YAML generation and Home Assistant restarts entirely. It provides a native `.storage`-backed sidebar dashboard that manages your Google Cloud credentials (`project_id`, `service_account`, `report_state`, `secure_devices_pin`), entity exposure, and voice nicknames in memory and syncs live to Google HomeGraph (`devices:requestSync`) with a single click—or automatically with a 15-second debounce. --- ## Key Features +- **100% `.storage`-Native & Zero-Reboot Live Sync (v3.0.0)**: + - Stores Google Cloud credentials, entity sync state, regex blocklists, and AI settings in `.storage/google_assistant_entity_console`. + - Updates Home Assistant Core's `GoogleConfig` in memory on every edit and pushes changes live to Google HomeGraph via **Sync to Google** (or optional 15s debounced auto-sync) without restarting Home Assistant. + - **Auto-Import & One-Click YAML Migration**: Automatically imports existing `project_id`, `service_account` (`SERVICE_ACCOUNT.json`), and `secrets.yaml` values on first run and provides a one-click **Migrate from YAML** button. - **Native Sidebar Panel**: Integrates into the Home Assistant sidebar as a native panel (`google-assistant-entity-console-panel`). -- **Dual Grouping Layouts**: +- **Slicing Sidebar & Dual Grouping Layouts**: - **Location-First (Default)**: Group entities by Floor → Room (Area) → Domain → Entity Row. - **Domain-First (Alternate)**: Group entities by Domain → Floor → Room (Area) → Entity Row. -- **Batch Controls**: Expand or collapse nested sections across an entire floor, room, or domain with a single click. - **Direct Row Interactions**: - **Exposure Status**: Toggle entity exposure state (*Exposed*, *Pending Add*, *Pending Remove*, *Not Exposed*). - **Inline Renaming**: Edit entity display names directly on the row. @@ -43,12 +46,17 @@ Configuring entities exposed to Google Assistant in Home Assistant traditionally - Suggest entity exposure based on natural-language requests (e.g., *"Expose all living room lights and climate controls"*). - Bulk and room-aware voice nickname generation using local LLMs (Ollama, LocalAI, vLLM), cloud endpoints (OpenAI, OpenRouter, Groq), or Home Assistant Assist conversation agents. - **Theme Inheritance**: Automatically inherits the active Home Assistant dashboard theme (light, dark, Mushroom, Catppuccin, Nord, etc.) via standard CSS custom properties (`--primary-background-color`, etc.). -- **YAML Generation & Reload**: Generates `gaGen_YYMMDD.yaml` with support for Home Assistant `!secret` and `!include` tags, and triggers configuration reloads via `homeassistant.reload_config_entry`. --- ## Screenshot Previews +### v3.0.0 `.storage` Live Sync Dashboard + + +### v3.0.0 `.storage` Google Assistant & AI Settings Modal + + ### Location-First Layout (`Floor -> Room -> Domain`)  @@ -86,17 +94,7 @@ Once installed and Home Assistant has restarted: 1. In Home Assistant, navigate to **Settings** > **Devices & Services**. 2. Click **Add Integration** in the bottom-right corner. 3. Search for **Google Assistant Entity Console** and select it. -4. Complete the configuration wizard. A new **Google Sync** panel will appear in your sidebar. - -### Linking with `configuration.yaml` - -To link the generated YAML configuration file to Home Assistant's built-in `google_assistant:` integration, add an include statement pointing to the generated file inside your `configuration.yaml`: - -```yaml -google_assistant: !include gaGen_062226.yaml -``` - -The console will read, update, and rewrite this file whenever a rebuild is triggered from the sidebar panel. +4. Open the **Google Sync** panel in your sidebar and click **Settings** to verify or configure your Google Cloud `project_id` and `SERVICE_ACCOUNT.json` credentials directly in `.storage` (or click **Migrate from YAML** to import and comment out any legacy `google_assistant:` block in `configuration.yaml`). --- @@ -114,7 +112,7 @@ For setup details and custom prompt templates, see the [AI Integration Guide](do ## Documentation -- **[Architecture Overview](docs/ARCHITECTURE.md)**: Details on backend views, entity registry resolution, YAML serialization, regex blocklists, and frontend Web Component architecture. +- **[Architecture Overview](docs/ARCHITECTURE.md)**: Details on backend views, entity registry resolution, `.storage` persistence, regex blocklists, and frontend Web Component architecture. - **[AI Integration Guide](docs/AI_INTEGRATION.md)**: Configuration details for OpenAI-compatible endpoints, HA Assist agents, and prompt customization. - **[Dashboard Theme Integration](docs/THEMING.md)**: Mapping of Home Assistant CSS custom properties to Material Design 3 design tokens. @@ -122,11 +120,8 @@ For setup details and custom prompt templates, see the [AI Integration Guide](do ## FAQ & Troubleshooting -### Q: Do I need to restart Home Assistant every time I change exposed entities? -**No.** After rebuilding the YAML configuration file in the console, select **Reload Config** to trigger `homeassistant.reload_config_entry` for zero-downtime updates without a core restart. - -### Q: Will this overwrite existing secrets or custom YAML tags? -**No.** The backend uses custom PyYAML `SafeDumper` and `SafeLoader` representers to preserve `!secret` and `!include` tags. +### Q: Do I need to restart Home Assistant or maintain YAML files when I change exposed entities? +**No.** Starting in v3.0.0, all entity exposure changes, nicknames, and rooms are updated in Home Assistant's live `GoogleConfig` memory immediately and synced directly to Google HomeGraph via **Sync to Google** (or automatic 15-second debounced sync) with zero restarts and zero YAML files. ### Q: Why are some entities missing from the console? Google Assistant supports specific Home Assistant domains (`light`, `switch`, `climate`, `lock`, `cover`, `fan`, `vacuum`, `media_player`, `alarm_control_panel`, `camera`, `scene`, `script`, etc.) and binary sensors with specific device classes (`door`, `garage_door`, `lock`, `opening`, `window`, etc.). Unsupported entities are filtered out automatically. diff --git a/brand/v3_live_sync_dashboard.png b/brand/v3_live_sync_dashboard.png new file mode 100644 index 0000000..ae60157 Binary files /dev/null and b/brand/v3_live_sync_dashboard.png differ diff --git a/brand/v3_storage_settings_modal.png b/brand/v3_storage_settings_modal.png new file mode 100644 index 0000000..efe0dda Binary files /dev/null and b/brand/v3_storage_settings_modal.png differ diff --git a/custom_components/google_assistant_entity_console/__init__.py b/custom_components/google_assistant_entity_console/__init__.py index 4bf5f9d..43a1bf9 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,8 +113,16 @@ 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") async_remove_panel(hass, DOMAIN, warn_if_unknown=False) return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle clean removal / uninstallation of the integration entry.""" + _LOGGER.info("Removing Google Assistant Entity Console config entry") + async_remove_panel(hass, DOMAIN, warn_if_unknown=False) + hass.data.pop(DOMAIN, None) 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/runtime.py b/custom_components/google_assistant_entity_console/runtime.py new file mode 100644 index 0000000..abaa6e9 --- /dev/null +++ b/custom_components/google_assistant_entity_console/runtime.py @@ -0,0 +1,327 @@ +"""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, + SERVICE_REQUEST_SYNC, +) +from homeassistant.components.google_assistant.const import ( + DOMAIN as GA_DOMAIN, +) +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")) + 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( + 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/custom_components/google_assistant_entity_console/static/app.js b/custom_components/google_assistant_entity_console/static/app.js index 2489c82..2c41d7f 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,73 @@ 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 exportYamlBtn = document.getElementById('exportYamlBtn'); + if (exportYamlBtn) { + exportYamlBtn.addEventListener('click', async () => { + exportYamlBtn.disabled = true; + const originalHtml = exportYamlBtn.innerHTML; + exportYamlBtn.innerHTML = ' Exporting...'; + try { + const response = await authFetch('/api/google_assistant_entity_console/rebuild', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({}) + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Export failed'); + } + const data = await response.json(); + showToast(`Exported ${data.exposed_count} entities to ${data.filename}! Add 'google_assistant: !include ${data.filename}' to configuration.yaml if uninstalling.`, 'success'); + } catch (error) { + showToast('Error exporting YAML: ' + error.message, 'error'); + } finally { + exportYamlBtn.disabled = false; + exportYamlBtn.innerHTML = originalHtml; + } + }); + } const closeSettings = () => { if (aiSettingsModal) aiSettingsModal.style.display = 'none'; @@ -1969,10 +2080,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 +2147,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..98f4254 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 @@