From b8637c3c5b853bd96dc22e62d2162465239e9e12 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Mon, 12 Oct 2026 21:39:31 -0500 Subject: [PATCH 01/11] docs: add design spec for async I/O, token refresh, and branding --- ...nc-io-token-refresh-and-branding-design.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-12-console-enhancements-async-io-token-refresh-and-branding-design.md diff --git a/docs/superpowers/specs/2026-10-12-console-enhancements-async-io-token-refresh-and-branding-design.md b/docs/superpowers/specs/2026-10-12-console-enhancements-async-io-token-refresh-and-branding-design.md new file mode 100644 index 0000000..dbe09ee --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-console-enhancements-async-io-token-refresh-and-branding-design.md @@ -0,0 +1,165 @@ +# Google Assistant Entity Console: Enhancements, Async I/O, Token Refresh, and Branding Design + +**Date:** 2026-10-12 +**Status:** Approved +**Author:** spelech / Antigravity + +--- + +## 1. Executive Summary + +This specification outlines key architectural, user experience, developer tooling, and branding enhancements for the **Google Assistant Entity Console** Home Assistant custom component: + +1. **Non-Blocking Async File I/O:** Eliminate event loop blocking (`ASYNC230`) across all backend endpoints by delegating synchronous file reads and writes to executor threads via `hass.async_add_executor_job`. +2. **Dynamic Auth Token Refresh:** Establish bidirectional messaging between the parent Home Assistant web component (`panel.js`) and the console Single-Page Application iframe (`app.js`) to automatically synchronize renewed access tokens and prevent 401 session expirations. +3. **YAML Include Directive Detection & Guidance:** Detect whether `configuration.yaml` includes the active `gaGen_*.yaml` configuration and present an actionable UI banner with a one-click copyable snippet if absent. +4. **Developer Tooling & Linting Standards:** Introduce a standardized `pyproject.toml` file enabling zero-argument `pytest` execution (`pythonpath = ["."]`) and clean up all 46 `ruff` warnings. +5. **Brand Logo Redesign:** Modernize the app and brand logo by adopting the iconic Home Assistant house network graph geometry infused with Google Assistant's signature four-color palette (`#4285F4`, `#EA4335`, `#FBBC05`, `#34A853`). + +--- + +## 2. Architecture & Backend Enhancements (`views.py`) + +### 2.1 Async File I/O Optimization +Currently, `load_blocklist`, `save_blocklist`, `load_ai_settings`, `save_ai_settings`, and `RebuildView.post` perform synchronous `with open(...)` operations on the main event loop thread. + +#### Changes: +- Define discrete synchronous helper functions: + - `_sync_read_json(filepath: str, default: Any) -> Any` + - `_sync_write_json(filepath: str, data: Any) -> None` + - `_sync_write_text(filepath: str, content: str) -> None` + - `_sync_update_config_yaml(config_path: str, filename: str) -> tuple[bool, str]` +- Convert `load_blocklist`, `save_blocklist`, `load_ai_settings`, and `save_ai_settings` into async functions or wrap their I/O invocations with `await hass.async_add_executor_job(...)`. +- In `RebuildView.post`, perform the file write of `gaGen_YYMMDD.yaml` and the check/update of `configuration.yaml` in executor jobs. +- Keep the exact file paths (`/config/google_assistant_entity_console_blocklist.json`, `/config/google_assistant_entity_console_ai_settings.json`, and `/config/configuration.yaml`) intact to ensure complete backward compatibility. + +### 2.2 Missing Include Directive Detection +In `RebuildView.post`: +- Read `configuration.yaml` via executor. +- Look for `include_pattern = r"google_assistant:\s*!include\s*gaGen_\d{6}\.yaml"`. +- If found: + - Replace with `f"google_assistant: !include {filename}"`. + - Write updated content. + - Return `{"success": True, "has_include_directive": True, "exposed_count": exposed_count, "yaml_written": filepath}`. +- If NOT found: + - Return `{"success": True, "has_include_directive": False, "required_include": f"google_assistant: !include {filename}", "exposed_count": exposed_count, "yaml_written": filepath}`. + +### 2.3 Linter Fixes in `views.py` +- Eliminate unused local variables in `views.py`: + - Lines 853-855: Remove unused `base_url`, `api_key`, `model` in `AISuggestExposureView`. + - Lines 917-919: Remove unused `base_url`, `api_key`, `model` in `AIGenerateSingleEntityNicknameView`. +- Catch specific exceptions where feasible or document broad error bounds. + +--- + +## 3. Frontend & Authentication Enhancements (`panel.js` & `app.js`) + +### 3.1 Token Refresh Architecture +Home Assistant frontend rotates authentication tokens periodically. The panel wrapper (`panel.js`) receives updated `hass` properties, but the nested iframe (`index.html`) relies on the initial query parameter. + +```mermaid +sequenceDiagram + autonumber + participant HA as Home Assistant Core / Frontend + participant Panel as panel.js (Web Component) + participant Iframe as app.js (SPA inside Iframe) + participant API as /api/google_assistant_entity_console/* + + HA->>Panel: set hass(hass) with refreshed accessToken + Panel->>Iframe: postMessage({ type: "HA_TOKEN_UPDATE", token }) + Iframe->>Iframe: sessionStorage.setItem('ha_token', token) + + Note over Iframe,API: On Token Expiry Fallback + Iframe->>API: HTTP Request (stale token) + API-->>Iframe: 401 Unauthorized + Iframe->>Panel: postMessage({ type: "REQUEST_HA_TOKEN" }) + Panel-->>Iframe: postMessage({ type: "HA_TOKEN_UPDATE", token }) + Iframe->>API: Retry HTTP Request with renewed token + API-->>Iframe: 200 OK +``` + +#### Implementation in `panel.js`: +- Store `this._hass = hass;` +- On `set hass(hass)`: + - If `this._iframe && this._iframe.contentWindow && hass.auth?.accessToken`: + - Dispatch `this._iframe.contentWindow.postMessage({ type: 'HA_TOKEN_UPDATE', token: hass.auth.accessToken }, '*')`. +- Add window event listener for messages from the child iframe: + - When `{ type: 'REQUEST_HA_TOKEN' }` is received, respond with `{ type: 'HA_TOKEN_UPDATE', token: this._hass?.auth?.accessToken }`. + +#### Implementation in `app.js`: +- Add message listener: + ```javascript + window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'HA_TOKEN_UPDATE' && event.data.token) { + sessionStorage.setItem('ha_token', event.data.token); + } + }); + ``` +- Wrap `fetch` calls with an authenticated client helper that intercepts 401 errors, requests a token renewal, and retries once before surfacing an error to the user. + +### 3.2 Missing YAML Include Notice in UI +- In `handleRebuild()` in `app.js`: + - Inspect `result.has_include_directive`. + - If `false`, display a dedicated modal or highlight in the restart dialog with the snippet: + ```yaml + google_assistant: !include + ``` + - Provide a "Copy to Clipboard" button and clear guidance that Home Assistant requires this directive in `configuration.yaml`. + +--- + +## 4. Developer Experience & Testing (`pyproject.toml` & Test Suite) + +### 4.1 `pyproject.toml` Configuration +Create `pyproject.toml` in repository root: +```toml +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +asyncio_mode = "strict" + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "ASYNC", "B", "SIM"] +ignore = ["ASYNC230"] # for test files reading static test fixtures if necessary +``` + +### 4.2 Test Suite Cleanups +- In `tests/conftest.py`: Convert mutable class attribute `extra_urls = []` to initialized instance property or `ClassVar`. +- Clean import ordering across `tests/test_blocklist.py` and `tests/test_views.py`. +- Consolidate nested `with` blocks into single compound `with` statements. +- Ensure all 336 existing tests pass cleanly without `PYTHONPATH=.`. + +--- + +## 5. Brand & App Logo Redesign + +### 5.1 Design Specifications +- **Base Geometry:** Home Assistant's iconic interconnected house network graph. + - Roof, walls, and chimney framework formed by nodes and clean geometric edges. +- **Color Palette (Google Assistant signature colors):** + - **Google Blue:** `#4285F4` (Roof peak and primary apex node) + - **Google Red:** `#EA4335` (Accent node / branch) + - **Google Yellow:** `#FBBC05` (Central living node / bridge) + - **Google Green:** `#34A853` (Foundation / ground node) + - **Neutral Framework:** `#5F6368` or clean semi-transparent connecting paths with rounded caps. +- **Style:** Flat, modern, vector-based, high contrast on both dark and light surfaces. + +### 5.2 Asset Outputs +- `brand/logo.svg`: Master vector source. +- `brand/logo.png`: 1024x1024 master transparent PNG. +- `brand/icon.png`: 1024x1024 master icon PNG. +- `custom_components/google_assistant_entity_console/static/logo.png`: Optimized web asset for dashboard header. +- Synchronized to `/ha/823SCD-Docker/custom_components/google_assistant_entity_console/static/logo.png`. + +--- + +## 6. Verification & Validation Plan +1. **Unit Tests:** Execute `pytest` directly to ensure 336/336 tests pass. +2. **Linting:** Execute `ruff check .` to confirm 0 errors. +3. **Async Verification:** Inspect views to ensure zero blocking `open()` calls in async contexts. +4. **Token Refresh Verification:** Simulate token update postMessage events and verify `sessionStorage` updates. +5. **Asset Inspection:** Verify rendered logo dimensions and transparency. From 9dc43eadf7b0b35c5627eb505545e504c0c9a562 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Mon, 12 Oct 2026 21:41:35 -0500 Subject: [PATCH 02/11] docs: add implementation plan for async IO, token refresh, and branding --- ...nts-async-io-token-refresh-and-branding.md | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-12-console-enhancements-async-io-token-refresh-and-branding.md diff --git a/docs/superpowers/plans/2026-10-12-console-enhancements-async-io-token-refresh-and-branding.md b/docs/superpowers/plans/2026-10-12-console-enhancements-async-io-token-refresh-and-branding.md new file mode 100644 index 0000000..6a75030 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-console-enhancements-async-io-token-refresh-and-branding.md @@ -0,0 +1,273 @@ +# Console Enhancements, Async I/O, Token Refresh, and Branding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Modernize Google Assistant Entity Console with non-blocking async file I/O, auto-refreshing authentication tokens, missing YAML include guidance, clean developer tooling (`pyproject.toml` and ruff), and a new Google-colored Home Assistant brand logo. + +**Architecture:** Refactor synchronous file I/O in `views.py` to use `hass.async_add_executor_job`, introduce bidirectional `postMessage` synchronization between `panel.js` and `app.js` for token resilience, detect missing `!include` directives in `configuration.yaml` during rebuilds with interactive UI guidance, set up standardized `pyproject.toml`, and generate SVG/PNG logo assets applying Google Assistant's 4-color palette to the Home Assistant network graph. + +**Tech Stack:** Python 3.12, Home Assistant custom components (`aiohttp`, `homeassistant.core`, `homeassistant.components.frontend`), PyYAML, pytest, pytest-asyncio, Ruff, Vanilla JavaScript (ES6+), SVG / Pillow (Python imaging). + +## Global Constraints + +- Preserve all existing file paths (`configuration.yaml`, `gaGen_*.yaml`, `google_assistant_entity_console_*.json`). +- Zero blocking file I/O on the Home Assistant main event loop. +- All 336 existing unit tests must continue to pass. +- Code must pass `ruff check .` with zero errors. +- Both repository files (`/drives/nfs/repos/google-assistant-entity-console`) and HA container files (`/ha/823SCD-Docker/custom_components/google_assistant_entity_console`) must remain in sync. + +--- + +### Task 1: Developer Tooling (`pyproject.toml`) and Conftest Cleanups + +**Files:** +- Create: `pyproject.toml` +- Modify: `tests/conftest.py` +- Test: `tests/test_yaml.py` + +**Interfaces:** +- Produces: `pyproject.toml` defining pytest root and pythonpath `["."]`, ruff target version py312. + +- [ ] **Step 1: Create `pyproject.toml`** + +Write `pyproject.toml` in repository root: +```toml +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +asyncio_mode = "strict" + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "SIM"] +ignore = ["E501", "B008", "SIM108"] +``` + +- [ ] **Step 2: Fix `tests/conftest.py` mutable class attribute** + +Update line 137 in `tests/conftest.py`: +Change `extra_urls = []` inside class definition to initialize `self.extra_urls = []` in `__init__` or annotate as `ClassVar`. + +- [ ] **Step 3: Run pytest directly without `PYTHONPATH=.`** + +Run: `pytest` +Expected: 336 passed in ~1.5s. + +- [ ] **Step 4: Commit** + +```bash +git add pyproject.toml tests/conftest.py +git commit -m "chore: add pyproject.toml and fix conftest mutable class attribute" +``` + +--- + +### Task 2: Non-Blocking Async File I/O in `views.py` + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/views.py` +- Test: `tests/test_blocklist.py`, `tests/test_views.py`, `tests/test_yaml.py` + +**Interfaces:** +- Consumes: `hass.async_add_executor_job` +- Produces: Async helper functions `async_load_blocklist`, `async_save_blocklist`, `async_load_ai_settings`, `async_save_ai_settings`, non-blocking `RebuildView.post`. + +- [ ] **Step 1: Write tests for async file helpers** + +Add test cases in `tests/test_blocklist.py` verifying that blocklist read/write operations execute asynchronously without blocking the event loop and preserve JSON contents. + +- [ ] **Step 2: Implement file helper functions in `views.py`** + +Define: +```python +def _sync_read_json(filepath: str, default): + if not os.path.exists(filepath): + return default + try: + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + return data + except Exception as err: + _LOGGER.error("Failed to load JSON from %s: %s", filepath, err) + return default + +def _sync_write_json(filepath: str, data) -> None: + try: + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + except Exception as err: + _LOGGER.error("Failed to save JSON to %s: %s", filepath, err) + +def _sync_write_file(filepath: str, content: str) -> None: + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) +``` + +Update `load_blocklist` and `save_blocklist`, `load_ai_settings` and `save_ai_settings` to support both async and sync execution (or convert views to await executor job). In views (`BlocklistView`, `BlocklistAddView`, `AISettingsView`, `RebuildView`, `EntitiesView`), await the executor job so file I/O does not stall the loop. + +- [ ] **Step 3: Run pytest to verify all existing and new tests pass** + +Run: `pytest` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add custom_components/google_assistant_entity_console/views.py tests/ +git commit -m "perf: offload file I/O to executor jobs in views.py" +``` + +--- + +### Task 3: Missing YAML Include Detection in Backend and UI + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/views.py` +- Modify: `custom_components/google_assistant_entity_console/static/app.js` +- Modify: `custom_components/google_assistant_entity_console/static/index.html` +- Test: `tests/test_yaml.py` + +**Interfaces:** +- Produces: `RebuildView` JSON response fields: `has_include_directive: bool`, `required_include: str`. + +- [ ] **Step 1: Write test for missing include directive in `tests/test_yaml.py`** + +Test that when `configuration.yaml` does not have `google_assistant: !include ...`, `RebuildView` returns `has_include_directive: false` and the snippet string. + +- [ ] **Step 2: Update `RebuildView.post` in `views.py`** + +```python +has_include = bool(re.search(include_pattern, config_content)) +if has_include: + # replace with filename +... +return self.json({ + "success": True, + "has_include_directive": has_include, + "required_include": f"google_assistant: !include {filename}", + "exposed_count": exposed_count, + "yaml_written": filepath +}) +``` + +- [ ] **Step 3: Update `handleRebuild` in `app.js` and add missing include dialog in `index.html`** + +When `result.has_include_directive === false`, display a highlighted banner in the modal showing the required snippet with a "Copy Snippet" button. + +- [ ] **Step 4: Run pytest** + +Run: `pytest tests/test_yaml.py` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add custom_components/google_assistant_entity_console/ tests/test_yaml.py +git commit -m "feat: add missing configuration.yaml include detection and UI snippet copy" +``` + +--- + +### Task 4: Dynamic Auth Token Refresh in `panel.js` and `app.js` + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/static/panel.js` +- Modify: `custom_components/google_assistant_entity_console/static/app.js` + +**Interfaces:** +- `panel.js` posts `{ type: "HA_TOKEN_UPDATE", token: string }` +- `app.js` listens for `HA_TOKEN_UPDATE` and posts `{ type: "REQUEST_HA_TOKEN" }` upon 401. + +- [ ] **Step 1: Update `panel.js`** + +Implement `this._hass = hass` and postMessage dispatch on token change: +```javascript +if (this._iframe && this._iframe.contentWindow && hass.auth && hass.auth.accessToken) { + this._iframe.contentWindow.postMessage({ + type: "HA_TOKEN_UPDATE", + token: hass.auth.accessToken + }, "*"); +} +``` +Add message event listener for `REQUEST_HA_TOKEN`. + +- [ ] **Step 2: Update `app.js`** + +Add message listener for `HA_TOKEN_UPDATE`. Wrap `fetch` calls in an authenticated fetch wrapper `apiFetch(url, options)` that detects 401 responses, requests a token update from parent window, and retries once. + +- [ ] **Step 3: Commit** + +```bash +git add custom_components/google_assistant_entity_console/static/panel.js custom_components/google_assistant_entity_console/static/app.js +git commit -m "feat: add automatic auth token synchronization and 401 retry handling" +``` + +--- + +### Task 5: Brand & App Logo Redesign (Google Colors on Home Assistant Logo) + +**Files:** +- Create: `brand/logo.svg` +- Update: `brand/logo.png` +- Update: `brand/icon.png` +- Update: `custom_components/google_assistant_entity_console/static/logo.png` + +**Interfaces:** +- SVG and PNGs depicting the Home Assistant house network graph with Google Assistant colors: + - Blue: `#4285F4` + - Red: `#EA4335` + - Yellow: `#FBBC05` + - Green: `#34A853` + +- [ ] **Step 1: Create master vector `brand/logo.svg`** + +Craft the Home Assistant geometric house network outline and internal nodes using Google Assistant colors with modern styling. + +- [ ] **Step 2: Generate PNG exports** + +Use Python with Pillow / Cairo / resvg or svg2png script to render crisp transparent 1024x1024 PNGs for `brand/logo.png`, `brand/icon.png`, and a 256x256 asset for `custom_components/google_assistant_entity_console/static/logo.png`. + +- [ ] **Step 3: Verify image outputs** + +Verify dimensions, transparency, and visual quality. + +- [ ] **Step 4: Commit** + +```bash +git add brand/ custom_components/google_assistant_entity_console/static/logo.png +git commit -m "style: redesign brand and app logo with Google Assistant colors on HA house graph" +``` + +--- + +### Task 6: Ruff Cleanups, Full Verification, and Container Sync + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/views.py` +- Modify: `tests/` +- Target: `/ha/823SCD-Docker/custom_components/google_assistant_entity_console/` + +- [ ] **Step 1: Clean up remaining Ruff warnings** + +Remove unused variables in `views.py` (lines 853-855, 917-919). Format test imports and combine nested `with` statements. +Run `ruff check .` +Expected: 0 errors. + +- [ ] **Step 2: Run full test suite** + +Run `pytest` +Expected: All tests pass. + +- [ ] **Step 3: Synchronize changes to Home Assistant Docker directory** + +Copy updated component files to `/ha/823SCD-Docker/custom_components/google_assistant_entity_console/`. + +- [ ] **Step 4: Commit and finalize** + +```bash +git add -A +git commit -m "chore: clean up linter warnings and finalize v1.7.1 release polish" +``` From 299d57d23d637ae2e61b65f33cfe7e532d02e771 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Mon, 12 Oct 2026 21:43:09 -0500 Subject: [PATCH 03/11] chore: add pyproject.toml and fix conftest mutable class attribute --- pyproject.toml | 12 ++++++++++++ tests/conftest.py | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..86ac380 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +asyncio_mode = "strict" + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "SIM"] +ignore = ["E501", "B008", "SIM108"] diff --git a/tests/conftest.py b/tests/conftest.py index 2345186..e022756 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -134,7 +134,9 @@ class HomeAssistantView: url = None name = None requires_auth = True - extra_urls = [] + + def __init__(self): + self.extra_urls = [] def json(self, result, status_code: int = 200, headers: dict | None = None): return web.json_response(result, status=status_code, headers=headers) From 2b529e7b745646aba88f30f0ed3bea002755a4ea Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Mon, 12 Oct 2026 21:45:13 -0500 Subject: [PATCH 04/11] perf: offload file I/O to executor jobs in views.py and add include detection --- .../google_assistant_entity_console/views.py | 154 ++++++++++-------- tests/test_blocklist.py | 13 ++ tests/test_views.py | 16 ++ tests/test_yaml.py | 4 + 4 files changed, 122 insertions(+), 65 deletions(-) diff --git a/custom_components/google_assistant_entity_console/views.py b/custom_components/google_assistant_entity_console/views.py index c5b0d6f..5b6f200 100644 --- a/custom_components/google_assistant_entity_console/views.py +++ b/custom_components/google_assistant_entity_console/views.py @@ -69,33 +69,78 @@ def extract_yaml_header(content: str | None) -> list[str]: import json +from typing import Any -def load_blocklist(hass: HomeAssistant) -> list: - filepath = hass.config.path("google_assistant_entity_console_blocklist.json") + +def _sync_read_json(filepath: str, default: Any) -> Any: if not os.path.exists(filepath): - return [] + return default try: with open(filepath, "r", encoding="utf-8") as f: data = json.load(f) - if isinstance(data, list): - return data + return data except Exception as err: - _LOGGER.error("Failed to load blocklist: %s", err) - return [] + _LOGGER.error("Failed to load JSON from %s: %s", filepath, err) + return default -def save_blocklist(hass: HomeAssistant, blocklist: list): - filepath = hass.config.path("google_assistant_entity_console_blocklist.json") + +def _sync_write_json(filepath: str, data: Any) -> None: try: with open(filepath, "w", encoding="utf-8") as f: - json.dump(blocklist, f, indent=2) + json.dump(data, f, indent=2) except Exception as err: - _LOGGER.error("Failed to save blocklist: %s", err) + _LOGGER.error("Failed to save JSON to %s: %s", filepath, err) + + +def _sync_write_file(path: str, content: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def _sync_update_config_include(config_path: str, filename: str) -> bool: + if not os.path.exists(config_path): + return False + with open(config_path, "r", encoding="utf-8") as f: + config_content = f.read() + + include_pattern = r"google_assistant:\s*!include\s*(gaGen_\d{6}\.yaml)" + if re.search(include_pattern, config_content): + updated = re.sub( + include_pattern, + f"google_assistant: !include {filename}", + config_content, + ) + with open(config_path, "w", encoding="utf-8") as f: + f.write(updated) + return True + return False + + +def load_blocklist(hass: HomeAssistant) -> list: + filepath = hass.config.path("google_assistant_entity_console_blocklist.json") + data = _sync_read_json(filepath, default=[]) + return data if isinstance(data, list) else [] + + +def save_blocklist(hass: HomeAssistant, blocklist: list) -> None: + filepath = hass.config.path("google_assistant_entity_console_blocklist.json") + _sync_write_json(filepath, blocklist) + + +async def async_load_blocklist(hass: HomeAssistant) -> list: + return await hass.async_add_executor_job(load_blocklist, hass) + + +async def async_save_blocklist(hass: HomeAssistant, blocklist: list) -> None: + await hass.async_add_executor_job(save_blocklist, hass, blocklist) + def _read_file_content(path): with open(path, "r", encoding="utf-8") as f: return f.read() + async def get_current_yaml_filename(hass: HomeAssistant): config_path = hass.config.path("configuration.yaml") try: @@ -187,7 +232,7 @@ async def async_fetch_entities_data(hass: HomeAssistant): device_name_map[device.id] = device.name_by_user or device.name # Load blocklist - blocklist = load_blocklist(hass) + blocklist = await async_load_blocklist(hass) compiled_blocklist = [] for pattern in blocklist: try: @@ -483,30 +528,21 @@ class CustomLoader(yaml.SafeLoader): except Exception as val_err: return self.json({"error": f"Generated YAML failed validation: {val_err}"}, status_code=500) - # Write file - with open(filepath, "w", encoding="utf-8") as f: - f.write(cleaned_yaml) + # Write file via executor job + await hass.async_add_executor_job(_sync_write_file, filepath, cleaned_yaml) - # Dynamically update configuration.yaml + # Dynamically update configuration.yaml via executor job config_path = hass.config.path("configuration.yaml") - if os.path.exists(config_path): - with open(config_path, "r", encoding="utf-8") as f: - config_content = f.read() - - include_pattern = r"google_assistant:\s*!include\s*gaGen_\d{6}\.yaml" - if re.search(include_pattern, config_content): - config_content = re.sub( - include_pattern, - f"google_assistant: !include {filename}", - config_content - ) - with open(config_path, "w", encoding="utf-8") as f: - f.write(config_content) - else: - _LOGGER.warning("Could not find google_assistant !include line in configuration.yaml") + has_include = await hass.async_add_executor_job( + _sync_update_config_include, config_path, filename + ) + if not has_include: + _LOGGER.warning("Could not find google_assistant !include line in configuration.yaml") return self.json({ "success": True, + "has_include_directive": has_include, + "required_include": f"google_assistant: !include {filename}", "exposed_count": exposed_count, "yaml_written": filepath }) @@ -536,7 +572,7 @@ class BlocklistView(HomeAssistantView): async def get(self, request: web.Request) -> web.Response: hass = request.app["hass"] try: - blocklist = load_blocklist(hass) + blocklist = await async_load_blocklist(hass) return self.json({"blocklist": blocklist}) except Exception as err: _LOGGER.exception("Failed to get blocklist") @@ -561,7 +597,7 @@ async def post(self, request: web.Request) -> web.Response: except (re.error, TypeError) as err: return self.json({"error": f"Invalid regex pattern '{pattern}': {err}"}, status_code=400) - save_blocklist(hass, blocklist) + await async_save_blocklist(hass, blocklist) return self.json({"success": True, "blocklist": blocklist}) except Exception as err: _LOGGER.exception("Failed to update blocklist") @@ -587,10 +623,10 @@ async def post(self, request: web.Request) -> web.Response: except (re.error, TypeError) as err: return self.json({"error": f"Invalid regex pattern: {err}"}, status_code=400) - blocklist = load_blocklist(hass) + blocklist = await async_load_blocklist(hass) if pattern not in blocklist: blocklist.append(pattern) - save_blocklist(hass, blocklist) + await async_save_blocklist(hass, blocklist) return self.json({"success": True, "blocklist": blocklist}) except Exception as err: @@ -601,24 +637,21 @@ async def post(self, request: web.Request) -> web.Response: # AI Settings Storage Helpers def load_ai_settings(hass: HomeAssistant) -> dict: filepath = hass.config.path("google_assistant_entity_console_ai_settings.json") - if not os.path.exists(filepath): - return {} - try: - with open(filepath, "r", encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, dict): - return data - except Exception as err: - _LOGGER.error("Failed to load AI settings: %s", err) - return {} + data = _sync_read_json(filepath, default={}) + return data if isinstance(data, dict) else {} + -def save_ai_settings(hass: HomeAssistant, settings: dict): +def save_ai_settings(hass: HomeAssistant, settings: dict) -> None: filepath = hass.config.path("google_assistant_entity_console_ai_settings.json") - try: - with open(filepath, "w", encoding="utf-8") as f: - json.dump(settings, f, indent=2) - except Exception as err: - _LOGGER.error("Failed to save AI settings: %s", err) + _sync_write_json(filepath, settings) + + +async def async_load_ai_settings(hass: HomeAssistant) -> dict: + return await hass.async_add_executor_job(load_ai_settings, hass) + + +async def async_save_ai_settings(hass: HomeAssistant, settings: dict) -> None: + await hass.async_add_executor_job(save_ai_settings, hass, settings) # AI API Views @@ -709,7 +742,7 @@ class AISettingsView(HomeAssistantView): async def get(self, request: web.Request) -> web.Response: hass = request.app["hass"] try: - settings = load_ai_settings(hass) + settings = await async_load_ai_settings(hass) return self.json({"settings": settings}) except Exception as err: return self.json({"error": str(err)}, status_code=500) @@ -722,7 +755,7 @@ async def post(self, request: web.Request) -> web.Response: if not isinstance(settings, dict): return self.json({"error": "Settings must be a dictionary"}, status_code=400) - save_ai_settings(hass, settings) + await async_save_ai_settings(hass, settings) return self.json({"success": True, "settings": settings}) except Exception as err: return self.json({"error": str(err)}, status_code=500) @@ -795,10 +828,7 @@ async def post(self, request: web.Request) -> web.Response: if not entities_to_gen: return self.json({"error": "No entities provided"}, status_code=400) - settings = load_ai_settings(hass) - base_url = settings.get("base_url", "").rstrip("/") - api_key = settings.get("api_key", "") - model = settings.get("model", "") + settings = await async_load_ai_settings(hass) prompt_template = settings.get("nickname_prompt", "").strip() # Default fallback template @@ -849,10 +879,7 @@ async def post(self, request: web.Request) -> web.Response: if not user_intent: return self.json({"error": "No user intent/criteria provided"}, status_code=400) - settings = load_ai_settings(hass) - base_url = settings.get("base_url", "").rstrip("/") - api_key = settings.get("api_key", "") - model = settings.get("model", "") + settings = await async_load_ai_settings(hass) prompt_template = settings.get("exposure_prompt", "").strip() # Default fallback template @@ -913,10 +940,7 @@ async def post(self, request: web.Request) -> web.Response: if not entity_id: return self.json({"error": "Missing entity_id"}, status_code=400) - settings = load_ai_settings(hass) - base_url = settings.get("base_url", "").rstrip("/") - api_key = settings.get("api_key", "") - model = settings.get("model", "") + settings = await async_load_ai_settings(hass) prompt_template = settings.get("single_nickname_prompt", "").strip() # Default fallback template diff --git a/tests/test_blocklist.py b/tests/test_blocklist.py index b4670a8..6548b8d 100644 --- a/tests/test_blocklist.py +++ b/tests/test_blocklist.py @@ -10,6 +10,8 @@ BlocklistAddView, BlocklistView, async_fetch_entities_data, + async_load_blocklist, + async_save_blocklist, load_blocklist, save_blocklist, ) @@ -44,6 +46,17 @@ def test_save_and_load_blocklist_roundtrip(mock_hass, tmp_path): assert loaded == patterns +@pytest.mark.anyio +async def test_async_save_and_load_blocklist_roundtrip(mock_hass, tmp_path): + """Saves list of patterns asynchronously and reloads accurately.""" + mock_hass.config.path.side_effect = lambda filename: str(tmp_path / filename) + patterns = [r"^switch\.secret_.*", r".*_power$"] + + await async_save_blocklist(mock_hass, patterns) + loaded = await async_load_blocklist(mock_hass) + assert loaded == patterns + + def test_load_blocklist_corrupted_json(mock_hass, tmp_path): """Handles corrupted JSON file gracefully by returning an empty list [].""" mock_hass.config.path.side_effect = lambda filename: str(tmp_path / filename) diff --git a/tests/test_views.py b/tests/test_views.py index 2b5928d..30b2eb5 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -31,6 +31,8 @@ UpdateEntityView, async_call_llm, async_fetch_entities_data, + async_load_ai_settings, + async_save_ai_settings, load_ai_settings, save_ai_settings, ) @@ -70,6 +72,20 @@ def test_save_and_load_ai_settings_roundtrip(mock_hass, tmp_path): assert loaded == sample_settings +@pytest.mark.anyio +async def test_async_save_and_load_ai_settings_roundtrip(mock_hass, tmp_path): + """Persists settings asynchronously and accurately reloads them.""" + mock_hass.config.path.side_effect = lambda filename: str(tmp_path / filename) + sample_settings = { + "ai_source": "home_assistant", + "ha_agent_id": "conversation.home_assistant", + } + + await async_save_ai_settings(mock_hass, sample_settings) + loaded = await async_load_ai_settings(mock_hass) + assert loaded == sample_settings + + def test_load_ai_settings_corrupted_json(mock_hass, tmp_path): """Handles corrupted JSON file gracefully by returning empty dict {}.""" mock_hass.config.path.side_effect = lambda filename: str(tmp_path / filename) diff --git a/tests/test_yaml.py b/tests/test_yaml.py index f0b994f..941dc8e 100644 --- a/tests/test_yaml.py +++ b/tests/test_yaml.py @@ -398,6 +398,8 @@ async def test_yaml_header_rebuild_view_preserves_existing_header(mock_hass, tmp data = json.loads(resp.body.decode()) assert data["success"] is True assert data["exposed_count"] == 1 + assert data["has_include_directive"] is True + assert "required_include" in data written_path = data["yaml_written"] with open(written_path, "r", encoding="utf-8") as f: @@ -441,6 +443,8 @@ async def test_yaml_header_rebuild_view_defaults_when_no_existing_file(mock_hass resp = await view.post(request) data = json.loads(resp.body.decode()) assert data["success"] is True + assert data["has_include_directive"] is False + assert data["required_include"].startswith("google_assistant: !include gaGen_") written_path = data["yaml_written"] with open(written_path, "r", encoding="utf-8") as f: From dd8c513df1d92a18de4a2845130de0fd33bec2cc Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Mon, 12 Oct 2026 21:46:25 -0500 Subject: [PATCH 05/11] feat: add token refresh sync, authFetch wrapper, and missing include guidance UI --- .../static/app.js | 119 ++++++++++++++---- .../static/index.html | 16 ++- .../static/panel.js | 37 +++++- 3 files changed, 143 insertions(+), 29 deletions(-) diff --git a/custom_components/google_assistant_entity_console/static/app.js b/custom_components/google_assistant_entity_console/static/app.js index bd9fa8d..778390e 100644 --- a/custom_components/google_assistant_entity_console/static/app.js +++ b/custom_components/google_assistant_entity_console/static/app.js @@ -39,6 +39,23 @@ const blocklistContainer = document.getElementById('blocklistContainer'); const closeBlocklistModal = document.getElementById('closeBlocklistModal'); const closeBlocklistModalBtn = document.getElementById('closeBlocklistModalBtn'); +// Listen for token updates from parent panel +window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'HA_TOKEN_UPDATE' && event.data.token) { + sessionStorage.setItem('ha_token', event.data.token); + } +}); + +function requestParentToken() { + try { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'REQUEST_HA_TOKEN' }, '*'); + } + } catch (e) { + console.debug('Failed to request token from parent window:', e); + } +} + // Get auth headers from URL token parameter or session storage function getAuthHeaders() { const params = new URLSearchParams(window.location.search); @@ -57,6 +74,26 @@ function getAuthHeaders() { return {}; } +// Authenticated fetch wrapper that handles token injection and 401 refresh retry +async function authFetch(url, options = {}) { + const headers = { + ...getAuthHeaders(), + ...(options.headers || {}) + }; + let response = await fetch(url, { ...options, headers }); + if (response.status === 401) { + requestParentToken(); + // Wait briefly for token update from parent + await new Promise(resolve => setTimeout(resolve, 300)); + const retryHeaders = { + ...getAuthHeaders(), + ...(options.headers || {}) + }; + response = await fetch(url, { ...options, headers: retryHeaders }); + } + return response; +} + // Init if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { @@ -72,7 +109,7 @@ if (document.readyState === 'loading') { async function fetchEntities() { showTableLoading(); try { - const response = await fetch('/api/google_assistant_entity_console/entities', { + const response = await authFetch('/api/google_assistant_entity_console/entities', { headers: getAuthHeaders() }); if (!response.ok) throw new Error('Failed to fetch entities'); @@ -137,6 +174,23 @@ function setupEventListeners() { // Rebuild Button if (rebuildBtn) rebuildBtn.addEventListener('click', handleRebuild); + + // Copy Include Snippet Button + const copyIncludeBtn = document.getElementById('copyIncludeBtn'); + if (copyIncludeBtn) { + copyIncludeBtn.addEventListener('click', () => { + const input = document.getElementById('missingIncludeInput'); + if (input && input.value) { + navigator.clipboard.writeText(input.value).then(() => { + showToast('Include snippet copied to clipboard!', 'success'); + }).catch(() => { + input.select(); + document.execCommand('copy'); + showToast('Include snippet copied to clipboard!', 'success'); + }); + } + }); + } // Restart Modal controls if (cancelRestartBtn && restartModal) { @@ -776,7 +830,7 @@ window.openQuickAliasModal = function(entityId, event) { const updatedAliases = [...entity.aliases, val]; try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -830,22 +884,33 @@ async function handleRebuild() { icon.className = 'fa-solid fa-spinner fa-spin'; try { - const response = await fetch('/api/google_assistant_entity_console/rebuild', { + const result = await authFetch('/api/google_assistant_entity_console/rebuild', { method: 'POST', headers: { - 'Content-Type': 'application/json', - ...getAuthHeaders() + 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - if (!response.ok) { - const err = await response.json(); + if (!result.ok) { + const err = await result.json(); throw new Error(err.error || 'Rebuild failed'); } - const result = await response.json(); - showToast(`Configuration generated successfully. (${result.exposed_count} entities exposed)`, 'success'); + 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'; + } + } + // Open restart confirmation modal restartModal.style.display = 'block'; } catch (error) { @@ -863,7 +928,7 @@ async function handleRestart() { confirmRestartBtn.textContent = 'Restarting...'; try { - const response = await fetch('/api/google_assistant_entity_console/restart', { + const response = await authFetch('/api/google_assistant_entity_console/restart', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -901,7 +966,7 @@ window.toggleExposeDirectly = async function(entityId, event) { const newExposeStatus = !entity.should_expose; try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -936,7 +1001,7 @@ window.removeAliasDirectly = async function(entityId, alias, event) { const updatedAliases = (entity.aliases || []).filter(a => a !== alias); try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1007,7 +1072,7 @@ window.openEditNameInline = function(entityId, event) { } try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1073,7 +1138,7 @@ function showToast(message, type = 'success') { async function fetchBlocklist() { blocklistContainer.innerHTML = 'Loading blocklist...'; try { - const response = await fetch('/api/google_assistant_entity_console/blocklist', { + const response = await authFetch('/api/google_assistant_entity_console/blocklist', { headers: getAuthHeaders() }); if (!response.ok) throw new Error('Failed to fetch blocklist'); @@ -1116,7 +1181,7 @@ function renderBlocklist(patterns) { // Add a regex pattern to blocklist async function addBlocklistPattern(pattern) { try { - const response = await fetch('/api/google_assistant_entity_console/blocklist/add', { + const response = await authFetch('/api/google_assistant_entity_console/blocklist/add', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1143,7 +1208,7 @@ async function addBlocklistPattern(pattern) { async function removeBlocklistPattern(pattern) { try { // Fetch current list first - const getRes = await fetch('/api/google_assistant_entity_console/blocklist', { + const getRes = await authFetch('/api/google_assistant_entity_console/blocklist', { headers: getAuthHeaders() }); if (!getRes.ok) throw new Error('Failed to load current list'); @@ -1151,7 +1216,7 @@ async function removeBlocklistPattern(pattern) { const updatedList = (getData.blocklist || []).filter(p => p !== pattern); - const response = await fetch('/api/google_assistant_entity_console/blocklist', { + const response = await authFetch('/api/google_assistant_entity_console/blocklist', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1185,7 +1250,7 @@ window.addToBlocklistDirectly = async function(entityId, event) { const pattern = `^${entityId.replace(/\./g, '\\.')}$`; try { - const response = await fetch('/api/google_assistant_entity_console/blocklist/add', { + const response = await authFetch('/api/google_assistant_entity_console/blocklist/add', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1220,7 +1285,7 @@ let aiSettings = {}; async function loadAiSettings() { try { - const response = await fetch('/api/google_assistant_entity_console/ai/settings', { + const response = await authFetch('/api/google_assistant_entity_console/ai/settings', { headers: getAuthHeaders() }); if (response.ok) { @@ -1309,7 +1374,7 @@ function setupAiEventListeners() { queryModelsBtn.disabled = true; queryModelsBtn.textContent = 'Fetching...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/models', { + const response = await authFetch('/api/google_assistant_entity_console/ai/models', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1368,7 +1433,7 @@ function setupAiEventListeners() { saveAiSettingsBtn.disabled = true; saveAiSettingsBtn.textContent = 'Saving...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/settings', { + const response = await authFetch('/api/google_assistant_entity_console/ai/settings', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1427,7 +1492,7 @@ function setupAiEventListeners() { queryHaAgentsBtn.disabled = true; queryHaAgentsBtn.textContent = 'Fetching...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/ha_agents', { + const response = await authFetch('/api/google_assistant_entity_console/ai/ha_agents', { headers: getAuthHeaders() }); if (!response.ok) { @@ -1487,7 +1552,7 @@ function setupAiEventListeners() { aiGenNicknamesBtn.innerHTML = ' Generating...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/generate_nicknames', { + const response = await authFetch('/api/google_assistant_entity_console/ai/generate_nicknames', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1555,7 +1620,7 @@ function setupAiEventListeners() { aiSuggestExposureBtn.innerHTML = ' Analyzing...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/suggest_exposure', { + const response = await authFetch('/api/google_assistant_entity_console/ai/suggest_exposure', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1704,7 +1769,7 @@ function getEntitiesInGroup(targetType, targetId) { // Helpers for Direct updates async function setExposureDirectly(entityId, newExposeStatus) { try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1730,7 +1795,7 @@ async function setAliasesDirectly(entityId, aliases) { try { const entity = entities.find(e => e.entity_id === entityId); if (!entity) return false; - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1765,7 +1830,7 @@ window.generateSingleEntityNickname = async function(entityId, event) { showToast(`AI suggesting nicknames for ${entity.display_name}...`, 'info'); try { - const response = await fetch('/api/google_assistant_entity_console/ai/generate_single_nickname', { + const response = await authFetch('/api/google_assistant_entity_console/ai/generate_single_nickname', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/custom_components/google_assistant_entity_console/static/index.html b/custom_components/google_assistant_entity_console/static/index.html index 3f524dd..a13b141 100644 --- a/custom_components/google_assistant_entity_console/static/index.html +++ b/custom_components/google_assistant_entity_console/static/index.html @@ -180,10 +180,24 @@

Manage Regex Blocklist